Просмотр исходного кода

Merge branch 'master' into prisma

mizozobu 2 недель назад
Родитель
Сommit
acbe3cb98e
70 измененных файлов с 2704 добавлено и 672 удалено
  1. 18 1
      .claude/skills/monorepo-overview/SKILL.md
  2. 2 2
      .github/workflows/ci-app-prod.yml
  3. 268 0
      .kiro/specs/collaborative-editor/design.md
  4. 79 0
      .kiro/specs/collaborative-editor/requirements.md
  5. 69 0
      .kiro/specs/collaborative-editor/research.md
  6. 22 0
      .kiro/specs/collaborative-editor/spec.json
  7. 3 0
      .kiro/specs/collaborative-editor/tasks.md
  8. 262 0
      .kiro/specs/upgrade-fixed-packages/design.md
  9. 75 0
      .kiro/specs/upgrade-fixed-packages/requirements.md
  10. 183 0
      .kiro/specs/upgrade-fixed-packages/research.md
  11. 22 0
      .kiro/specs/upgrade-fixed-packages/spec.json
  12. 89 0
      .kiro/specs/upgrade-fixed-packages/tasks.md
  13. 5 1
      .kiro/steering/structure.md
  14. 1 1
      apps/app/.claude/rules/package-dependencies.md
  15. 90 0
      apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md
  16. 9 1
      apps/app/bin/check-next-symlinks.sh
  17. 0 1
      apps/app/next.config.ts
  18. 8 13
      apps/app/package.json
  19. 1 0
      apps/app/public/static/locales/en_US/admin.json
  20. 1 0
      apps/app/public/static/locales/fr_FR/admin.json
  21. 1 0
      apps/app/public/static/locales/ja_JP/admin.json
  22. 1 0
      apps/app/public/static/locales/ko_KR/admin.json
  23. 1 0
      apps/app/public/static/locales/zh_CN/admin.json
  24. 21 1
      apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx
  25. 16 0
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  26. 11 0
      apps/app/src/client/components/Admin/MarkdownSetting/ContentDispositionSettings.tsx
  27. 6 9
      apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx
  28. 1 2
      apps/app/src/features/openai/server/services/openai.ts
  29. 9 8
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  30. 12 0
      apps/app/src/interfaces/session-config.ts
  31. 6 14
      apps/app/src/server/crowi/index.ts
  32. 1 2
      apps/app/src/server/models/obsolete-page.js
  33. 7 8
      apps/app/src/server/models/page.ts
  34. 1 2
      apps/app/src/server/routes/apiv3/users.js
  35. 31 20
      apps/app/src/server/service/file-uploader/aws/index.ts
  36. 1 5
      apps/app/src/server/service/page-grant.ts
  37. 9 18
      apps/app/src/server/service/page/index.ts
  38. 3 0
      apps/app/src/server/service/socket-io/socket-io.ts
  39. 61 20
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts
  40. 159 0
      apps/app/src/server/service/yjs/guard-socket.spec.ts
  41. 30 0
      apps/app/src/server/service/yjs/guard-socket.ts
  42. 2 2
      apps/app/src/server/service/yjs/sync-ydoc.ts
  43. 177 0
      apps/app/src/server/service/yjs/upgrade-handler.spec.ts
  44. 131 0
      apps/app/src/server/service/yjs/upgrade-handler.ts
  45. 205 0
      apps/app/src/server/service/yjs/websocket-connection.integ.ts
  46. 39 0
      apps/app/src/server/service/yjs/y-websocket-server.d.ts
  47. 25 10
      apps/app/src/server/service/yjs/yjs.integ.ts
  48. 72 93
      apps/app/src/server/service/yjs/yjs.ts
  49. 1 1
      apps/app/src/stores-universal/use-next-themes.tsx
  50. 17 7
      apps/app/src/stores/renderer.tsx
  51. 1 1
      apps/app/tsconfig.build.client.json
  52. 2 3
      apps/slackbot-proxy/package.json
  53. 2 4
      packages/core-styles/package.json
  54. 6 0
      packages/core/CHANGELOG.md
  55. 3 6
      packages/core/package.json
  56. 5 0
      packages/core/src/consts/ydoc-status.ts
  57. 9 0
      packages/core/src/index.ts
  58. 2 2
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  59. 1 3
      packages/core/src/utils/page-path-utils/generate-children-regexp.ts
  60. 4 9
      packages/core/src/utils/page-path-utils/index.ts
  61. 4 6
      packages/editor/package.json
  62. 59 73
      packages/editor/src/client/stores/use-collaborative-editor-mode.ts
  63. 21 17
      packages/editor/vite.config.ts
  64. 7 0
      packages/pluginkit/CHANGELOG.md
  65. 1 1
      packages/pluginkit/package.json
  66. 1 1
      packages/preset-themes/package.json
  67. 1 4
      packages/remark-lsx/package.json
  68. 12 4
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  69. 4 5
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  70. 295 291
      pnpm-lock.yaml

+ 18 - 1
.claude/skills/monorepo-overview/SKILL.md

@@ -99,11 +99,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<T>`, `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:

+ 2 - 2
.github/workflows/ci-app-prod.yml

@@ -40,7 +40,7 @@ concurrency:
 jobs:
 
   # test-prod-node22:
-  #   uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@dev/7.5.x
+  #   uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
   #   if: |
   #     ( github.event_name == 'push'
   #       || github.base_ref == 'master'
@@ -54,7 +54,7 @@ jobs:
   #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   test-prod-node24:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@dev/7.5.x
+    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'

+ 268 - 0
.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<YDocStatus>;
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
+  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<void>;
+  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 |

+ 79 - 0
.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.

+ 69 - 0
.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)

+ 22 - 0
.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
+}

+ 3 - 0
.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.

+ 262 - 0
.kiro/specs/upgrade-fixed-packages/design.md

@@ -0,0 +1,262 @@
+# Design Document: upgrade-fixed-packages
+
+## Overview
+
+**Purpose**: This feature audits and upgrades version-pinned packages in `apps/app/package.json` that were frozen due to upstream bugs, ESM-only migrations, or licensing constraints. The build environment has shifted from webpack to Turbopack, and the runtime now targets Node.js 24 with stable `require(esm)` support, invalidating several original pinning reasons.
+
+**Users**: Maintainers and developers benefit from up-to-date dependencies with bug fixes, security patches, and reduced technical debt.
+
+**Impact**: Modifies `apps/app/package.json` dependency versions and comment blocks; touches source files where `escape-string-regexp` is replaced by native `RegExp.escape()`.
+
+### Goals
+- Verify each pinning reason against current upstream status
+- Upgrade packages where the original constraint no longer applies
+- Replace `escape-string-regexp` with native `RegExp.escape()` (Node.js 24)
+- Update or remove comment blocks to reflect current state
+- Produce audit documentation for future reference
+
+### Non-Goals
+- Replacing handsontable with an alternative library (license constraint remains; replacement is a separate initiative)
+- Upgrading `@keycloak/keycloak-admin-client` to v19+ (significant API breaking changes; deferred to separate task)
+- Major version upgrades of unrelated packages
+- Modifying the build pipeline or Turbopack configuration
+
+## Architecture
+
+This is a dependency maintenance task, not a feature implementation. No new components or architectural changes are introduced.
+
+### Existing Architecture Analysis
+
+The pinned packages fall into distinct categories by their usage context:
+
+| Category | Packages | Build Context |
+|----------|----------|---------------|
+| Server-only (tsc → CJS) | `escape-string-regexp`, `@aws-sdk/*`, `@keycloak/*` | Express server compiled by tsc |
+| Client-only (Turbopack) | `string-width` (via @growi/editor), `bootstrap` | Bundled by Turbopack/Vite |
+| Client + SSR | `next-themes` | Turbopack + SSR rendering |
+| License-pinned | `handsontable`, `@handsontable/react` | Client-only |
+
+Key enabler: Node.js ^24 provides stable `require(esm)` support, removing the fundamental CJS/ESM incompatibility that caused several pins.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Runtime | Node.js ^24 | Enables `require(esm)` and `RegExp.escape()` | ES2026 Stage 4 features available |
+| Build (client) | Turbopack (Next.js 16) | Bundles ESM-only packages without issues | No changes needed |
+| Build (server) | tsc (CommonJS output) | `require(esm)` handles ESM-only imports | Node.js 24 native support |
+| Package manager | pnpm v10 | Manages dependency resolution | No changes needed |
+
+## System Flows
+
+### Upgrade Verification Flow
+
+```mermaid
+flowchart TD
+    Start[Select package to upgrade] --> Update[Update version in package.json]
+    Update --> Install[pnpm install]
+    Install --> Build{turbo run build}
+    Build -->|Pass| Lint{turbo run lint}
+    Build -->|Fail| Revert[Revert package change]
+    Lint -->|Pass| Test{turbo run test}
+    Lint -->|Fail| Revert
+    Test -->|Pass| Verify[Verify .next/node_modules symlinks]
+    Test -->|Fail| Revert
+    Verify --> Next[Proceed to next package]
+    Revert --> Document[Document failure reason]
+    Document --> Next
+```
+
+Each package is upgraded and verified independently. Failures are isolated and reverted without affecting other upgrades.
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Action |
+|-------------|---------|------------|--------|
+| 1.1 | Bootstrap bug investigation | PackageAudit | Verify #39798 fixed in v5.3.4 |
+| 1.2 | next-themes issue investigation | PackageAudit | Verify #122 resolved; check v0.4.x compatibility |
+| 1.3 | @aws-sdk constraint verification | PackageAudit | Confirm mongodb constraint is on different package |
+| 1.4 | Document investigation results | AuditReport | Summary table in research.md |
+| 2.1 | ESM compatibility per package | PackageAudit | Assess escape-string-regexp, string-width, @keycloak |
+| 2.2 | Server build ESM support | PackageAudit | Verify Node.js 24 require(esm) for server context |
+| 2.3 | Client build ESM support | PackageAudit | Confirm Turbopack handles ESM-only packages |
+| 2.4 | Compatibility matrix | AuditReport | Table in research.md |
+| 3.1 | Handsontable license check | PackageAudit | Confirm v7+ still non-MIT |
+| 3.2 | Document pinning requirement | AuditReport | Note in audit summary |
+| 4.1 | Update package.json versions and comments | UpgradeExecution | Modify versions and comment blocks |
+| 4.2 | Build verification | UpgradeExecution | `turbo run build --filter @growi/app` |
+| 4.3 | Lint verification | UpgradeExecution | `turbo run lint --filter @growi/app` |
+| 4.4 | Test verification | UpgradeExecution | `turbo run test --filter @growi/app` |
+| 4.5 | Revert on failure | UpgradeExecution | Git revert per package |
+| 4.6 | Update comment blocks | UpgradeExecution | Remove or update comments |
+| 5.1 | Audit summary table | AuditReport | Final summary with decisions |
+| 5.2 | Document continued pinning | AuditReport | Reasons for remaining pins |
+| 5.3 | Document upgrade rationale | AuditReport | What changed upstream |
+
+## Components and Interfaces
+
+| Component | Domain | Intent | Req Coverage | Key Dependencies |
+|-----------|--------|--------|--------------|------------------|
+| PackageAudit | Investigation | Research upstream status for each pinned package | 1.1–1.4, 2.1–2.4, 3.1–3.2 | GitHub issues, npm registry |
+| UpgradeExecution | Implementation | Apply version changes and verify build | 4.1–4.6 | pnpm, turbo, tsc |
+| SourceMigration | Implementation | Replace escape-string-regexp with RegExp.escape() | 4.1 | 9 source files |
+| AuditReport | Documentation | Produce summary of all decisions | 5.1–5.3 | research.md |
+
+### Investigation Layer
+
+#### PackageAudit
+
+| Field | Detail |
+|-------|--------|
+| Intent | Investigate upstream status of each pinned package and determine upgrade feasibility |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2 |
+
+**Responsibilities & Constraints**
+- Check upstream issue trackers for bug fix status
+- Verify ESM compatibility against Node.js 24 `require(esm)` and Turbopack
+- Confirm license status for handsontable
+- Produce actionable recommendation per package
+
+**Audit Decision Matrix**
+
+| Package | Current | Action | Target | Risk | Rationale |
+|---------|---------|--------|--------|------|-----------|
+| `bootstrap` | `=5.3.2` | Upgrade | `^5.3.4` | Low | Bug #39798 fixed in v5.3.4 |
+| `next-themes` | `^0.2.1` | Upgrade | `^0.4.4` | Medium | Original issue was misattributed; v0.4.x works with Pages Router |
+| `escape-string-regexp` | `^4.0.0` | Replace | Remove dep | Low | Native `RegExp.escape()` in Node.js 24 |
+| `string-width` | `=4.2.2` | Upgrade | `^7.0.0` | Low | Used only in ESM context (@growi/editor) |
+| `@aws-sdk/client-s3` | `3.454.0` | Relax | `^3.454.0` | Low | Pinning comment was misleading |
+| `@aws-sdk/s3-request-presigner` | `3.454.0` | Relax | `^3.454.0` | Low | Same as above |
+| `@keycloak/keycloak-admin-client` | `^18.0.0` | Defer | No change | N/A | API breaking changes; separate task |
+| `handsontable` | `=6.2.2` | Keep | No change | N/A | License constraint (non-MIT since v7) |
+| `@handsontable/react` | `=2.1.0` | Keep | No change | N/A | Requires handsontable >= 7 |
+
+### Implementation Layer
+
+#### UpgradeExecution
+
+| Field | Detail |
+|-------|--------|
+| Intent | Apply version changes incrementally with build verification |
+| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5, 4.6 |
+
+**Responsibilities & Constraints**
+- Upgrade one package at a time to isolate failures
+- Run full verification suite (build, lint, test) after each change
+- Revert and document any package that causes failures
+- Update `// comments for dependencies` block to reflect new state
+
+**Upgrade Order** (lowest risk first):
+1. `@aws-sdk/*` — relax version range (no code changes)
+2. `string-width` — upgrade in @growi/editor (isolated ESM package)
+3. `bootstrap` — upgrade to ^5.3.4 (verify SCSS compilation)
+4. `escape-string-regexp` → `RegExp.escape()` — source code changes across 9 files
+5. `next-themes` — upgrade to ^0.4.x (review API changes across 12 files)
+
+**Implementation Notes**
+- After each upgrade, verify `.next/node_modules/` symlinks for Turbopack externalisation compliance (per `package-dependencies` rule)
+- For bootstrap: run `pnpm run pre:styles-commons` and `pnpm run pre:styles-components` to verify SCSS compilation
+- For next-themes: review v0.3.0 and v0.4.0 changelogs for breaking API changes before modifying code
+
+#### SourceMigration
+
+| Field | Detail |
+|-------|--------|
+| Intent | Replace all `escape-string-regexp` usage with native `RegExp.escape()` |
+| Requirements | 4.1 |
+
+**Files to Modify**:
+
+`apps/app/src/` (6 files):
+- `server/models/page.ts`
+- `server/service/page/index.ts`
+- `server/service/page-grant.ts`
+- `server/routes/apiv3/users.js`
+- `server/models/obsolete-page.js`
+- `features/openai/server/services/openai.ts`
+
+`packages/` (3 files):
+- `packages/core/src/utils/page-path-utils/` (2 files)
+- `packages/remark-lsx/src/server/routes/list-pages/index.ts`
+
+**Migration Pattern**:
+```typescript
+// Before
+import escapeStringRegexp from 'escape-string-regexp';
+const pattern = new RegExp(escapeStringRegexp(input));
+
+// After
+const pattern = new RegExp(RegExp.escape(input));
+```
+
+**Implementation Notes**
+- Remove `escape-string-regexp` from `apps/app/package.json` dependencies after migration
+- Remove from `packages/core/package.json` and `packages/remark-lsx/package.json` if listed
+- Verify `RegExp.escape()` TypeScript types are available (may need `@types/node` update or lib config)
+
+### Documentation Layer
+
+#### AuditReport
+
+| Field | Detail |
+|-------|--------|
+| Intent | Document all audit decisions for future maintainers |
+| Requirements | 5.1, 5.2, 5.3 |
+
+**Deliverables**:
+- Updated `// comments for dependencies` in package.json (only retained pins with current reasons)
+- Updated `// comments for defDependencies` (handsontable entries unchanged)
+- Summary in research.md with final decision per package
+
+**Updated Comment Blocks** (target state):
+
+```json
+{
+  "// comments for dependencies": {
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort.",
+    "next-themes": "(if upgrade fails) Document specific failure reason here"
+  },
+  "// comments for defDependencies": {
+    "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
+    "handsontable": "v7.0.0 or above is no longer MIT license."
+  }
+}
+```
+
+Note: The exact final state depends on which upgrades succeed. If all planned upgrades pass, only `@keycloak` and `handsontable` entries remain.
+
+## Testing Strategy
+
+### Build Verification (per package)
+- `turbo run build --filter @growi/app` — Turbopack client build + tsc server build
+- `ls apps/app/.next/node_modules/ | grep <package>` — Externalisation check
+- `pnpm run pre:styles-commons` — SCSS compilation (bootstrap only)
+
+### Lint Verification (per package)
+- `turbo run lint --filter @growi/app` — TypeScript type check + Biome
+
+### Unit/Integration Tests (per package)
+- `turbo run test --filter @growi/app` — Full test suite
+- For `RegExp.escape()` migration: run tests for page model, page service, page-grant service specifically
+
+### Regression Verification (final)
+- Full build + lint + test after all upgrades applied together
+- Verify `.next/node_modules/` symlink integrity via `check-next-symlinks.sh` (if available locally)
+
+## Migration Strategy
+
+```mermaid
+flowchart LR
+    Phase1[Phase 1: Low Risk] --> Phase2[Phase 2: Medium Risk]
+    Phase1 --> P1a[aws-sdk relax range]
+    Phase1 --> P1b[string-width upgrade]
+    Phase2 --> P2a[bootstrap upgrade]
+    Phase2 --> P2b[escape-string-regexp replace]
+    Phase2 --> P2c[next-themes upgrade]
+```
+
+- **Phase 1** (low risk): @aws-sdk range relaxation, string-width upgrade — minimal code changes
+- **Phase 2** (medium risk): bootstrap, escape-string-regexp replacement, next-themes — requires code review and/or source changes
+- Each upgrade is independently revertible
+- Deferred: @keycloak (high risk, separate task)
+- No change: handsontable (license constraint)

+ 75 - 0
.kiro/specs/upgrade-fixed-packages/requirements.md

@@ -0,0 +1,75 @@
+# Requirements Document
+
+## Introduction
+
+The `apps/app/package.json` file contains several packages whose versions are intentionally pinned due to ESM-only upgrades, upstream bugs, or licensing concerns. These pinning reasons were documented in `// comments for dependencies` and `// comments for defDependencies` comment blocks. Since the build environment has significantly changed (webpack → Turbopack), and upstream issues may have been resolved, a systematic audit is needed to determine which packages can now be safely upgraded.
+
+### Pinned Packages Inventory
+
+| # | Package | Current Version | Pinning Reason |
+|---|---------|----------------|----------------|
+| 1 | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` | `3.454.0` | Fix version above 3.186.0 required by mongodb@4.16.0 |
+| 2 | `@keycloak/keycloak-admin-client` | `^18.0.0` | 19.0.0+ exports only ESM |
+| 3 | `bootstrap` | `=5.3.2` | v5.3.3 has a bug (twbs/bootstrap#39798) |
+| 4 | `escape-string-regexp` | `^4.0.0` | 5.0.0+ exports only ESM |
+| 5 | `next-themes` | `^0.2.1` | 0.3.0 causes type error (pacocoursey/next-themes#122) |
+| 6 | `string-width` | `=4.2.2` | 5.0.0+ exports only ESM |
+| 7 | `@handsontable/react` | `=2.1.0` | v3 requires handsontable >= 7.0.0 |
+| 8 | `handsontable` | `=6.2.2` | v7.0.0+ is no longer MIT license |
+
+## Requirements
+
+### Requirement 1: Upstream Bug and Issue Investigation
+
+**Objective:** As a maintainer, I want to verify whether upstream bugs and issues that originally caused version pinning have been resolved, so that I can make informed upgrade decisions.
+
+#### Acceptance Criteria
+
+1. When investigating the bootstrap pinning, the audit process shall check the current status of https://github.com/twbs/bootstrap/issues/39798 and determine whether v5.3.3+ has fixed the reported bug.
+2. When investigating the next-themes pinning, the audit process shall check the current status of https://github.com/pacocoursey/next-themes/issues/122 and determine whether v0.3.0+ has resolved the type error.
+3. When investigating the @aws-sdk pinning, the audit process shall verify whether the mongodb version used in GROWI still requires the `>=3.186.0` constraint and whether the latest @aws-sdk versions are compatible.
+4. The audit process shall document the investigation result for each package, including: current upstream status, whether the original issue is resolved, and the recommended action (upgrade/keep/replace).
+
+### Requirement 2: ESM-Only Package Compatibility Assessment
+
+**Objective:** As a maintainer, I want to assess whether ESM-only versions of pinned packages are now compatible with the current Turbopack-based build environment, so that outdated CJS-only constraints can be removed.
+
+#### Acceptance Criteria
+
+1. When assessing ESM compatibility, the audit process shall evaluate each ESM-pinned package (`escape-string-regexp`, `string-width`, `@keycloak/keycloak-admin-client`) against the current build pipeline (Turbopack for client, tsc for server).
+2. When a package is used in server-side code (transpiled via tsc with `tsconfig.build.server.json`), the audit process shall verify whether the server build output format (CJS or ESM) supports importing ESM-only packages.
+3. When a package is used only in client-side code (bundled via Turbopack), the audit process shall confirm that Turbopack can resolve ESM-only packages without issues.
+4. The audit process shall produce a compatibility matrix showing each ESM-pinned package, its usage context (server/client/both), and whether upgrading to the ESM-only version is feasible.
+
+### Requirement 3: License Compliance Verification
+
+**Objective:** As a maintainer, I want to confirm that the handsontable/`@handsontable/react` licensing situation has not changed, so that I can determine whether these packages must remain pinned or can be replaced.
+
+#### Acceptance Criteria
+
+1. When evaluating handsontable, the audit process shall verify the current license of handsontable v7.0.0+ and confirm whether it remains non-MIT.
+2. If handsontable v7.0.0+ is still non-MIT, the audit process shall document that `handsontable` (`=6.2.2`) and `@handsontable/react` (`=2.1.0`) must remain pinned or an alternative library must be identified.
+3. If a MIT-licensed alternative to handsontable exists, the audit process shall note it as a potential replacement candidate (out of scope for this spec but documented for future work).
+
+### Requirement 4: Safe Upgrade Execution
+
+**Objective:** As a maintainer, I want to upgrade packages that are confirmed safe to update, so that the project benefits from bug fixes, security patches, and new features.
+
+#### Acceptance Criteria
+
+1. When upgrading a pinned package, the upgrade process shall update the version specifier in `apps/app/package.json` and remove or update the corresponding entry in the `// comments for dependencies` or `// comments for defDependencies` block.
+2. When a package is upgraded, the upgrade process shall verify that `turbo run build --filter @growi/app` completes successfully.
+3. When a package is upgraded, the upgrade process shall verify that `turbo run lint --filter @growi/app` completes without new errors.
+4. When a package is upgraded, the upgrade process shall verify that `turbo run test --filter @growi/app` passes without new failures.
+5. If a package upgrade causes build, lint, or test failures, the upgrade process shall revert that specific package change and document the failure reason.
+6. When all upgrades are complete, the `// comments for dependencies` and `// comments for defDependencies` blocks shall accurately reflect only the packages that remain pinned, with updated reasons if applicable.
+
+### Requirement 5: Audit Documentation
+
+**Objective:** As a maintainer, I want a clear record of the audit results, so that future maintainers understand which packages were evaluated and why decisions were made.
+
+#### Acceptance Criteria
+
+1. The audit process shall produce a summary table documenting each pinned package with: package name, previous version, new version (or "unchanged"), and rationale for the decision.
+2. When a package remains pinned, the documentation shall include the verified reason for continued pinning.
+3. When a package is upgraded, the documentation shall note what changed upstream that made the upgrade possible.

+ 183 - 0
.kiro/specs/upgrade-fixed-packages/research.md

@@ -0,0 +1,183 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Capture discovery findings for the pinned package audit and upgrade initiative.
+**Usage**: Inform design.md decisions; provide evidence for future maintainers.
+---
+
+## Summary
+- **Feature**: `upgrade-fixed-packages`
+- **Discovery Scope**: Extension (auditing existing dependency constraints)
+- **Key Findings**:
+  - Bootstrap bug (#39798) fixed in v5.3.4 — safe to upgrade to latest 5.3.x
+  - next-themes original issue (#122) was resolved long ago; upgrade to v0.4.x feasible but has Next.js 16 `cacheComponents` caveat
+  - Node.js ^24 enables stable `require(esm)`, unlocking ESM-only package upgrades for server code
+  - `escape-string-regexp` can be replaced entirely by native `RegExp.escape()` (ES2026, Node.js 24)
+  - handsontable license situation unchanged — must remain pinned at 6.2.2
+  - @aws-sdk pinning comment is misleading; packages can be freely upgraded
+
+## Research Log
+
+### Bootstrap v5.3.3 Bug (#39798)
+- **Context**: bootstrap pinned at `=5.3.2` due to modal header regression in v5.3.3
+- **Sources Consulted**: https://github.com/twbs/bootstrap/issues/39798, https://github.com/twbs/bootstrap/pull/41336
+- **Findings**:
+  - Issue CLOSED on 2025-04-03
+  - Fixed in v5.3.4 via PR #41336 (Fix modal and offcanvas header collapse)
+  - Bug: `.modal-header` lost `justify-content: space-between`, causing content collapse
+  - Latest stable: v5.3.8 (August 2025)
+- **Implications**: Safe to upgrade from `=5.3.2` to `^5.3.4`. Skip v5.3.3 entirely. Recommend `^5.3.4` or pin to latest `=5.3.8`.
+
+### next-themes Type Error (#122)
+- **Context**: next-themes pinned at `^0.2.1` due to reported type error in v0.3.0
+- **Sources Consulted**: https://github.com/pacocoursey/next-themes/issues/122, https://github.com/pacocoursey/next-themes/issues/375
+- **Findings**:
+  - Issue #122 CLOSED on 2022-06-02 — was specific to an old beta version (v0.0.13-beta.3), not v0.3.0
+  - The pinning reason was based on incomplete information; v0.2.0+ already had the fix
+  - Latest: v0.4.6 (March 2025). Peers: `react ^16.8 || ^17 || ^18 || ^19`
+  - **Caveat**: Issue #375 reports a bug with Next.js 16's `cacheComponents` feature — stale theme values when cached components reactivate
+  - PR #377 in progress to fix via `useSyncExternalStore`
+  - Without `cacheComponents`, v0.4.6 works fine with Next.js 16
+- **Implications**: Upgrade to v0.4.x is feasible. GROWI uses Pages Router (not App Router), so `cacheComponents` is likely not relevant. Breaking API changes between v0.2 → v0.4 need review. Used in 12 files across apps/app.
+
+### ESM-only Package Compatibility (escape-string-regexp, string-width, @keycloak)
+- **Context**: Three packages pinned to CJS-compatible versions because newer versions are ESM-only
+- **Sources Consulted**: Node.js v22.12.0 release notes (require(esm) enabled by default), TC39 RegExp.escape Stage 4, sindresorhus ESM guidance, npm package pages
+- **Findings**:
+
+  **escape-string-regexp** (^4.0.0):
+  - Used in 6 server-side files + 3 shared package files (all server context)
+  - Node.js 24 has stable `require(esm)` — ESM-only v5 would work
+  - **Better**: `RegExp.escape()` is ES2026 Stage 4, natively available in Node.js 24 (V8 support)
+  - Can eliminate the dependency entirely
+
+  **string-width** (=4.2.2):
+  - Used only in `packages/editor/src/models/markdown-table.js`
+  - `@growi/editor` has `"type": "module"` and builds with Vite (ESM context)
+  - No server-side value imports (only type imports in `sync-ydoc.ts`, erased at compile)
+  - Safe to upgrade to v7.x
+
+  **@keycloak/keycloak-admin-client** (^18.0.0):
+  - Used in 1 server-side file: `features/external-user-group/server/service/keycloak-user-group-sync.ts`
+  - Latest: v26.5.5 (February 2026)
+  - `require(esm)` in Node.js 24 should handle it, but API has significant breaking changes (v18 → v26)
+  - Sub-path exports need verification
+  - Higher risk upgrade — API surface changes expected
+
+- **Implications**: string-width is the easiest upgrade. escape-string-regexp should be replaced by native `RegExp.escape()`. @keycloak requires careful API migration and is higher risk.
+
+### @aws-sdk Pinning Analysis
+- **Context**: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner pinned at 3.454.0
+- **Sources Consulted**: mongodb package.json, npm registry, GROWI source code
+- **Findings**:
+  - Pinning comment says "required by mongodb@4.16.0" but is misleading
+  - mongodb@4.17.2 has `@aws-sdk/credential-providers: ^3.186.0` as **optional** dependency — a different package
+  - The S3 client packages are used directly by GROWI for file upload (server/service/file-uploader/aws/)
+  - Latest: @aws-sdk/client-s3@3.1014.0 (March 2026) — over 500 versions behind
+  - AWS SDK v3 follows semver; any 3.x should be compatible
+- **Implications**: Remove the misleading comment. Change from exact `3.454.0` to `^3.454.0` or update to latest. Low risk.
+
+### Handsontable License Status
+- **Context**: handsontable pinned at =6.2.2 (last MIT version), @handsontable/react at =2.1.0
+- **Sources Consulted**: handsontable.com/docs/software-license, npm, Hacker News discussion
+- **Findings**:
+  - v7.0.0+ (March 2019) switched from MIT to proprietary license — unchanged as of 2026
+  - Free "Hobby" license exists but restricted to non-commercial personal use
+  - Commercial use requires paid subscription
+  - MIT alternatives: AG Grid Community (most mature), Jspreadsheet CE, Univer (Apache 2.0)
+- **Implications**: Must remain pinned. No action possible without license purchase or library replacement. Library replacement is out of scope for this spec.
+
+## Design Decisions
+
+### Decision: Replace escape-string-regexp with native RegExp.escape()
+- **Context**: escape-string-regexp v5 is ESM-only; used in 9 files across server code
+- **Alternatives Considered**:
+  1. Upgrade to v5 with require(esm) support — works but adds unnecessary dependency
+  2. Replace with native `RegExp.escape()` — zero dependencies, future-proof
+- **Selected Approach**: Replace with `RegExp.escape()`
+- **Rationale**: Node.js 24 supports `RegExp.escape()` natively (ES2026 Stage 4). Eliminates a dependency entirely.
+- **Trade-offs**: Requires touching 9 files, but changes are mechanical (find-and-replace)
+- **Follow-up**: Verify `RegExp.escape()` is available in the project's Node.js 24 target
+
+### Decision: Upgrade string-width directly to v7.x
+- **Context**: Used only in @growi/editor (ESM package, Vite-bundled, client-only)
+- **Selected Approach**: Direct upgrade to latest v7.x
+- **Rationale**: Consumer is already ESM; zero CJS concern
+- **Trade-offs**: None significant; API is stable
+
+### Decision: Upgrade bootstrap to ^5.3.4
+- **Context**: Bug fixed in v5.3.4; latest is 5.3.8
+- **Selected Approach**: Change from `=5.3.2` to `^5.3.4`
+- **Rationale**: Original bug resolved; skip v5.3.3
+- **Trade-offs**: Need to verify GROWI's custom SCSS and modal usage against 5.3.4+ changes
+
+### Decision: Upgrade next-themes to latest 0.4.x
+- **Context**: Original issue was a misunderstanding; latest is v0.4.6
+- **Selected Approach**: Upgrade to `^0.4.4` (or latest)
+- **Rationale**: Issue #122 was specific to old beta, not v0.3.0. GROWI uses Pages Router, so cacheComponents bug is not relevant.
+- **Trade-offs**: Breaking API changes between v0.2 → v0.4 need review. 12 files import from next-themes.
+- **Follow-up**: Review v0.3.0 and v0.4.0 changelogs for breaking changes
+
+### Decision: Relax @aws-sdk version to caret range
+- **Context**: Pinning was based on misleading comment; packages are independent of mongodb constraint
+- **Selected Approach**: Change from `3.454.0` to `^3.454.0`
+- **Rationale**: AWS SDK v3 follows semver; the comment conflated credential-providers with S3 client
+- **Trade-offs**: Low risk. Conservative approach keeps minimum at 3.454.0.
+
+### Decision: Defer @keycloak upgrade (high risk)
+- **Context**: v18 → v26 has significant API breaking changes; only 1 file affected
+- **Selected Approach**: Document as upgradeable but defer to a separate task
+- **Rationale**: API migration requires Keycloak server compatibility testing; out of proportion for a batch upgrade task
+- **Trade-offs**: Remains on old version longer, but isolated to one feature
+
+### Decision: Keep handsontable pinned (license constraint)
+- **Context**: v7+ is proprietary; no free alternative that's drop-in
+- **Selected Approach**: No change. Document for future reference.
+- **Rationale**: License constraint is permanent unless library is replaced entirely
+- **Trade-offs**: None — this is a business/legal decision, not technical
+
+## Risks & Mitigations
+- **Bootstrap SCSS breakage**: v5.3.4+ may have SCSS variable changes → Run `pre:styles-commons` and `pre:styles-components` builds to verify
+- **next-themes API changes**: v0.2 → v0.4 has breaking changes → Review changelog; test all 12 consuming files
+- **RegExp.escape() availability**: Ensure Node.js 24 V8 includes it → Verify with simple runtime test
+- **@aws-sdk transitive dependency changes**: Newer AWS SDK may pull different transitive deps → Monitor bundle size
+- **Build regression**: Any upgrade could break Turbopack build → Follow incremental upgrade strategy with build verification per package
+
+## Future Considerations (Out of Scope)
+
+### transpilePackages cleanup in next.config.ts
+- **Context**: `next.config.ts` defines `getTranspilePackages()` listing 60+ ESM-only packages to force Turbopack to bundle them instead of externalising. The original comment says: "listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM".
+- **Relationship to require(esm)**: `transpilePackages` and `require(esm)` solve different problems. `transpilePackages` prevents Turbopack from externalising packages during SSR; `require(esm)` allows Node.js to load ESM packages via `require()` at runtime. With Node.js 24's stable `require(esm)`, externalised ESM packages *should* load correctly in SSR, meaning some `transpilePackages` entries may become unnecessary.
+- **Why not now**: (1) Turbopack's `esmExternals` handling is still `experimental`; (2) removing entries shifts packages from bundled to externalised, which means they appear in `.next/node_modules/` and must be classified as `dependencies` per the `package-dependencies` rule; (3) 60+ packages need individual verification. This is a separate investigation with a large blast radius.
+- **Recommendation**: Track as a separate task. Test by removing a few low-risk entries (e.g., `bail`, `ccount`, `zwitch`) and checking whether SSR still works with Turbopack externalisation + Node.js 24 `require(esm)`.
+
+## References
+- [Bootstrap issue #39798](https://github.com/twbs/bootstrap/issues/39798) — modal header regression, fixed in v5.3.4
+- [next-themes issue #122](https://github.com/pacocoursey/next-themes/issues/122) — type error, resolved in v0.2.0
+- [next-themes issue #375](https://github.com/pacocoursey/next-themes/issues/375) — Next.js 16 cacheComponents bug
+- [TC39 RegExp.escape() Stage 4](https://socket.dev/blog/tc39-advances-3-proposals-to-stage-4-regexp-escaping-float16array-and-redeclarable-global-eval) — ES2026
+- [Node.js require(esm) stability](https://joyeecheung.github.io/blog/2025/12/30/require-esm-in-node-js-from-experiment-to-stability/) — stable since Node.js 22.12.0
+- [Handsontable license change](https://handsontable.com/docs/javascript-data-grid/software-license/) — proprietary since v7.0.0
+
+## Final Audit Summary (2026-03-23)
+
+| Package | Previous Version | New Version | Action | Rationale |
+|---------|-----------------|-------------|--------|-----------|
+| `@aws-sdk/client-s3` | `3.454.0` | `^3.1014.0` | Upgraded | Pinning comment was misleading; S3 client is independent of mongodb constraint |
+| `@aws-sdk/s3-request-presigner` | `3.454.0` | `^3.1014.0` | Upgraded | Same as above |
+| `bootstrap` | `=5.3.2` | `^5.3.8` | Upgraded | Bug #39798 fixed in v5.3.4; SCSS compilation verified |
+| `escape-string-regexp` | `^4.0.0` | Removed | Replaced | Native `RegExp.escape()` (ES2026, Node.js 24) eliminates the dependency |
+| `string-width` | `=4.2.2` | `^7.0.0` | Upgraded | Used only in @growi/editor (ESM context, Vite-bundled) |
+| `next-themes` | `^0.2.1` | `^0.4.6` | Upgraded | Original issue #122 was misattributed; only change needed: type import path |
+| `@keycloak/keycloak-admin-client` | `^18.0.0` | Unchanged | Deferred | API breaking changes (v18→v26) require separate migration effort |
+| `handsontable` | `=6.2.2` | Unchanged | Kept | v7.0.0+ is proprietary (non-MIT license) |
+| `@handsontable/react` | `=2.1.0` | Unchanged | Kept | Requires handsontable >= 7.0.0 |
+
+### Additional Changes
+
+- Added `RegExp.escape()` TypeScript type declarations in `apps/app/src/@types/`, `packages/core/src/@types/`, and `packages/remark-lsx/src/@types/` (awaiting TypeScript built-in support)
+- Updated `tsconfig.build.client.json` to include `src/@types/**/*.d.ts` for Next.js build compatibility
+- Updated `generate-children-regexp.spec.ts` test expectations for `RegExp.escape()` output (escapes spaces as `\x20`)
+- Removed `escape-string-regexp` from `transpilePackages` in `next.config.ts`
+- Updated `bootstrap` version across 5 packages: apps/app, packages/editor, packages/core-styles, packages/preset-themes, apps/slackbot-proxy
+- Updated `// comments for dependencies` to retain only `@keycloak` entry with updated reason

+ 22 - 0
.kiro/specs/upgrade-fixed-packages/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "upgrade-fixed-packages",
+  "created_at": "2026-03-23T00:00:00Z",
+  "updated_at": "2026-03-23T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 89 - 0
.kiro/specs/upgrade-fixed-packages/tasks.md

@@ -0,0 +1,89 @@
+# Implementation Plan
+
+- [x] 1. Pre-implementation verification
+- [x] 1.1 Verify RegExp.escape() availability and TypeScript support
+  - Confirm `RegExp.escape()` is available at runtime in the project's Node.js 24 target
+  - Check whether TypeScript recognizes `RegExp.escape()` — may need `lib` config update or `@types/node` update
+  - If unavailable, fall back to upgrading `escape-string-regexp` to v5 with `require(esm)` instead
+  - _Requirements: 2.2_
+
+- [x] 1.2 Review next-themes v0.3.0 and v0.4.0 breaking API changes
+  - Read changelogs for v0.3.0 and v0.4.0 releases to identify breaking changes
+  - Map breaking changes to the 12 consuming files in apps/app
+  - Determine migration effort and document required code changes
+  - Confirm GROWI's Pages Router usage is unaffected by the cacheComponents bug (issue #375)
+  - _Requirements: 1.2_
+
+- [x] 2. Low-risk package upgrades
+- [x] 2.1 (P) Relax @aws-sdk version range
+  - Change `@aws-sdk/client-s3` from `3.454.0` to `^3.1014.0` in apps/app/package.json
+  - Change `@aws-sdk/s3-request-presigner` from `3.454.0` to `^3.1014.0`
+  - Update the misleading `"@aws-skd/*"` comment to reflect the actual reason or remove it
+  - Run `pnpm install` and verify build with `turbo run build --filter @growi/app`
+  - Run `turbo run test --filter @growi/app` to confirm no regressions
+  - _Requirements: 1.3, 4.1, 4.2, 4.4_
+
+- [x] 2.2 (P) Upgrade string-width in @growi/editor
+  - Update `string-width` from `=4.2.2` to `^7.0.0` in packages/editor/package.json
+  - Verify @growi/editor builds successfully (Vite, ESM context)
+  - Run `turbo run build --filter @growi/app` to confirm downstream build passes
+  - Run `turbo run test --filter @growi/app` to confirm no regressions
+  - Remove the `string-width` comment from apps/app/package.json `// comments for dependencies`
+  - _Requirements: 2.1, 2.3, 4.1, 4.2, 4.4_
+
+- [x] 3. Upgrade bootstrap to ^5.3.8
+  - Change `bootstrap` from `=5.3.2` to `^5.3.8` in apps/app/package.json and all other packages
+  - Run `pnpm install` to resolve the new version
+  - Run `pnpm run pre:styles-commons` and `pnpm run pre:styles-components` to verify SCSS compilation
+  - Run `turbo run build --filter @growi/app` to confirm Turbopack build passes
+  - Run `turbo run lint --filter @growi/app` to check for type or lint errors
+  - Run `turbo run test --filter @growi/app` to confirm no regressions
+  - Visually inspect modal headers if a dev server is available (original bug was modal header layout)
+  - Remove the `bootstrap` comment from `// comments for dependencies`
+  - If build or SCSS fails, revert and document the failure reason
+  - _Requirements: 1.1, 4.1, 4.2, 4.3, 4.4, 4.5_
+
+- [x] 4. Replace escape-string-regexp with native RegExp.escape()
+- [x] 4.1 Migrate all source files from escape-string-regexp to RegExp.escape()
+  - Replace `import escapeStringRegexp from 'escape-string-regexp'` and corresponding calls with `RegExp.escape()` in each file
+  - Files in apps/app/src: page.ts, page/index.ts, page-grant.ts, users.js, obsolete-page.js, openai.ts (6 files)
+  - Files in packages: core/src/utils/page-path-utils (2 files), remark-lsx/src/server/routes/list-pages/index.ts (1 file)
+  - Ensure each replacement preserves the exact same escaping behavior
+  - _Requirements: 4.1_
+
+- [x] 4.2 Remove escape-string-regexp dependency and verify
+  - Remove `escape-string-regexp` from apps/app/package.json dependencies
+  - Remove from packages/core and packages/remark-lsx package.json if listed
+  - Remove the `escape-string-regexp` comment from `// comments for dependencies`
+  - Remove `escape-string-regexp` entry from `transpilePackages` in next.config.ts
+  - Run `pnpm install` to update lockfile
+  - Run `turbo run build --filter @growi/app` to verify build
+  - Run `turbo run lint --filter @growi/app` to verify no type errors
+  - Run `turbo run test --filter @growi/app` to verify no regressions
+  - If RegExp.escape() has TypeScript issues, add type declaration or adjust lib config
+  - _Requirements: 2.1, 2.2, 4.1, 4.2, 4.3, 4.4, 4.5_
+
+- [x] 5. Upgrade next-themes to ^0.4.x
+- [x] 5.1 Update next-themes and adapt consuming code
+  - Change `next-themes` from `^0.2.1` to `^0.4.6` in apps/app/package.json
+  - Apply required API migration changes across the 12 consuming files identified in design
+  - Pay attention to any renamed exports, changed hook signatures, or provider prop changes
+  - Ensure `useTheme()` and `ThemeProvider` usage is compatible with v0.4.x API
+  - _Requirements: 1.2, 4.1_
+
+- [x] 5.2 Verify next-themes upgrade
+  - Run `turbo run build --filter @growi/app` to confirm build passes
+  - Run `turbo run lint --filter @growi/app` to check for type errors (original pinning was about types)
+  - Run `turbo run test --filter @growi/app` to confirm no regressions
+  - Remove the `next-themes` comment from `// comments for dependencies`
+  - If build or type errors occur, investigate whether the issue is the same as #122 or a new problem
+  - If upgrade fails, revert and document the reason; keep the pin with an updated comment
+  - _Requirements: 4.2, 4.3, 4.4, 4.5, 4.6_
+
+- [x] 6. Finalize audit documentation and comment blocks
+  - Verify `// comments for dependencies` block contains only packages that remain pinned (@keycloak if unchanged)
+  - Verify `// comments for defDependencies` block is accurate (handsontable entries unchanged)
+  - Update comment text to reflect current reasons where applicable
+  - Produce a final summary table in research.md documenting: package name, previous version, new version or "unchanged", and rationale
+  - Confirm all requirements are satisfied by reviewing the checklist against actual changes made
+  - _Requirements: 3.1, 3.2, 4.6, 5.1, 5.2, 5.3_

+ 5 - 1
.kiro/steering/structure.md

@@ -12,5 +12,9 @@ In full-stack packages (e.g., `apps/app`), server-side code (`src/server/`, mode
 
 For apps/app-specific examples and build tooling details, see `apps/app/.claude/skills/build-optimization/SKILL.md`.
 
+### The positioning of @growi/core.
+
+See: `.claude/skills/monorepo-overview/SKILL.md` — "@growi/core — Domain & Utilities Hub" section
+
 ---
-_Updated: 2026-03-03. apps/app details moved to `apps/app/.claude/skills/build-optimization/SKILL.md`._
+_Updated: 2026-03-24. @growi/core details moved to monorepo-overview SKILL.md (auto-loaded)._

+ 1 - 1
apps/app/.claude/rules/package-dependencies.md

@@ -24,7 +24,7 @@ ls apps/app/.next/node_modules/ | grep <package-name>
 |---|---|
 | `import foo from 'pkg'` at module level in SSR-executed code | `dependencies` |
 | `import type { Foo } from 'pkg'` only | `devDependencies` (type-erased at build) |
-| `await import('pkg')` inside `useEffect` / event handler | Check `.next/node_modules/` — may still be externalised |
+| `await import('pkg')` inside `useEffect` / event handler | Check `.next/node_modules/` — may still be externalised (see `fix-broken-next-symlinks` skill) |
 | Used only in `*.spec.ts`, build scripts, or CI | `devDependencies` |
 
 ## Common Misconceptions

+ 90 - 0
apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md

@@ -0,0 +1,90 @@
+---
+name: fix-broken-next-symlinks
+description: Fix broken symlinks in .next/node_modules/ — diagnose, decide allowlist vs dependencies, and verify
+---
+
+## IMPORTANT
+
+This document is a **mandatory step-by-step procedure**. When fixing broken symlinks, execute every step in order. In particular, verification **always** requires the full 3-command sequence: `build` → `assemble-prod.sh` → `check-next-symlinks.sh`. Never skip `assemble-prod.sh` — the symlink check is only meaningful after production assembly.
+
+## Problem
+
+Turbopack externalizes packages into `.next/node_modules/` as symlinks, even for packages imported only via dynamic `import()` inside `useEffect`. After `assemble-prod.sh` runs `pnpm deploy --prod`, `devDependencies` are excluded, breaking those symlinks. `check-next-symlinks.sh` detects these and fails the build.
+
+## Diagnosis
+
+### Step 1 — Reproduce locally
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+If the check reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`, proceed to Step 2.
+
+### Step 2 — Determine the fix
+
+Search all import sites of the broken package:
+
+```bash
+grep -rn "from ['\"]<package-name>['\"]" apps/app/src/
+grep -rn "import(['\"]<package-name>['\"])" apps/app/src/
+```
+
+Apply the decision tree:
+
+```
+Is the package imported ONLY via:
+  - `import type { ... } from 'pkg'`  (erased at compile time)
+  - `await import('pkg')` inside useEffect / event handler  (client-side only, never SSR)
+
+  YES → Add to ALLOWED_BROKEN in check-next-symlinks.sh  (Step 3a)
+  NO  → Move from devDependencies to dependencies          (Step 3b)
+```
+
+### Step 3a — Add to allowlist
+
+Edit `apps/app/bin/check-next-symlinks.sh`:
+
+```bash
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+  socket.io-client
+  <new-package>          # <-- add here
+)
+```
+
+Use the bare package name (e.g., `socket.io-client`), not the hashed symlink name (`socket.io-client-46e5ba4d4c848156`).
+
+### Step 3b — Move to dependencies
+
+In `apps/app/package.json`, move the package from `devDependencies` to `dependencies`, then run `pnpm install`.
+
+### Step 4 — Verify the fix
+
+Re-run the full sequence:
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+Expected output: `OK: All apps/app/.next/node_modules symlinks resolve correctly.`
+
+## Example
+
+`socket.io-client` is used in two files:
+- `src/states/socket-io/global-socket.ts` — `import type` + `await import()` inside `useEffect`
+- `src/features/admin/states/socket-io.ts` — `import type` + `import()` inside `useEffect`
+
+Both are client-only dynamic imports → added to `ALLOWED_BROKEN`, stays as `devDependencies`.
+
+## When to Apply
+
+- CI fails at "Check for broken symlinks in .next/node_modules" step
+- `check-next-symlinks.sh` reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`
+- After adding a new package or changing import patterns in apps/app

+ 9 - 1
apps/app/bin/check-next-symlinks.sh

@@ -11,6 +11,7 @@ ALLOWED_BROKEN=(
   fslightbox-react
   @emoji-mart/data
   @emoji-mart/react
+  socket.io-client
 )
 
 # Build a grep -v pattern from the allowlist
@@ -29,7 +30,14 @@ done | grep -v "${grep_args[@]}" || true)
 if [ -n "$broken" ]; then
   echo "ERROR: Broken symlinks found in $NEXT_MODULES:"
   echo "$broken"
-  echo "Move these packages from devDependencies to dependencies in apps/app/package.json."
+  echo ""
+  echo "Each broken package must be either:"
+  echo "  1. Moved from devDependencies to dependencies in apps/app/package.json"
+  echo "  2. Added to ALLOWED_BROKEN in this script (if only used via useEffect + dynamic import)"
+  echo ""
+  echo "Fix: Follow the step-by-step procedure in apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md"
+  echo "     You MUST execute every step in order — do NOT skip assemble-prod.sh when verifying."
+  echo "Ref: apps/app/.claude/rules/package-dependencies.md"
   exit 1
 fi
 

+ 0 - 1
apps/app/next.config.ts

@@ -28,7 +28,6 @@ const getTranspilePackages = (): string[] => {
     'decode-named-character-reference',
     'devlop',
     'fault',
-    'escape-string-regexp',
     'hastscript',
     'html-void-elements',
     'is-absolute-url',

+ 8 - 13
apps/app/package.json

@@ -61,17 +61,13 @@
     "version:premajor": "pnpm version premajor --preid=RC"
   },
   "// comments for dependencies": {
-    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
-    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
-    "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
-    "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "next-themes": "0.3.0 causes a type error: https://github.com/pacocoursey/next-themes/issues/122",
-    "string-width": "5.0.0 or above exports only ESM."
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort."
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "3.454.0",
-    "@aws-sdk/s3-request-presigner": "3.454.0",
+    "@aws-sdk/client-s3": "^3.1014.0",
+    "@aws-sdk/lib-storage": "^3.1014.0",
+    "@aws-sdk/s3-request-presigner": "^3.1014.0",
     "@azure/identity": "^4.4.1",
     "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.0",
@@ -134,7 +130,7 @@
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
-    "bootstrap": "=5.3.2",
+    "bootstrap": "^5.3.8",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
@@ -157,7 +153,6 @@
     "dotenv-flow": "^3.2.0",
     "downshift": "^8.2.3",
     "ejs": "^3.1.10",
-    "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "express": "^4.20.0",
     "express-bunyan-logger": "^1.3.3",
@@ -210,7 +205,7 @@
     "next": "^16.2.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
-    "next-themes": "^0.2.1",
+    "next-themes": "^0.4.6",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "nodemailer": "^6.9.15",
@@ -274,7 +269,7 @@
     "sanitize-filename": "^1.6.3",
     "simplebar-react": "^2.3.6",
     "socket.io": "^4.7.5",
-    "string-width": "=4.2.2",
+    "string-width": "^7.0.0",
     "superjson": "^2.2.2",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.3.2",
@@ -296,7 +291,7 @@
     "xss": "^1.0.15",
     "y-codemirror.next": "^0.3.5",
     "y-mongodb-provider": "^0.2.0",
-    "y-socket.io": "^1.1.3",
+    "y-websocket": "^2.0.4",
     "yjs": "^13.6.18",
     "zod": "^3.24.2"
   },

+ 1 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -881,6 +881,7 @@
     "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
     "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "disable_mode_explanation_cloud": "Audit log is currently disabled. To enable it, please update the app settings from the GROWI.cloud management screen.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 1 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -880,6 +880,7 @@
     "available_action_list_explanation": "Liste des actions pouvant être recherchées/vues",
     "action_list": "Liste d'actions",
     "disable_mode_explanation": "Cette fonctionnalité est désactivée. Afin de l'activer, mettre à jour <code>AUDIT_LOG_ENABLED</code> pour true.",
+    "disable_mode_explanation_cloud": "Le journal d'audit est actuellement désactivé. Pour l'activer, veuillez modifier les paramètres de l'application depuis l'écran de gestion GROWI.cloud.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 1 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -890,6 +890,7 @@
     "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
     "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "disable_mode_explanation_cloud": "現在、監査ログは無効になっています。有効にするには、GROWI.cloud の管理画面からアプリの設定を変更してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 1 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -881,6 +881,7 @@
     "available_action_list_explanation": "현재 설정에서 검색/볼 수 있는 작업 목록",
     "action_list": "작업 목록",
     "disable_mode_explanation": "감사 로그가 현재 비활성화되어 있습니다. 활성화하려면 환경 변수 <code>AUDIT_LOG_ENABLED</code>를 true로 설정하십시오.",
+    "disable_mode_explanation_cloud": "현재 감사 로그가 비활성화되어 있습니다. 활성화하려면 GROWI.cloud 관리 화면에서 앱 설정을 변경하십시오.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 1 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -890,6 +890,7 @@
     "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
     "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "disable_mode_explanation_cloud": "审计日志当前已禁用。要启用它,请从 GROWI.cloud 管理界面更改应用程序设置。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 21 - 1
apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -2,9 +2,16 @@ import type { FC } from 'react';
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { useGrowiAppIdForGrowiCloud, useGrowiCloudUri } from '~/states/global';
+
 export const AuditLogDisableMode: FC = () => {
   const { t } = useTranslation('admin');
 
+  const growiCloudUri = useGrowiCloudUri();
+  const growiAppIdForGrowiCloud = useGrowiAppIdForGrowiCloud();
+
+  const isCloud = growiCloudUri != null && growiAppIdForGrowiCloud != null;
+
   return (
     <div className="ccontainer-lg">
       <div className="container">
@@ -21,9 +28,22 @@ export const AuditLogDisableMode: FC = () => {
               <h3
                 // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
                 dangerouslySetInnerHTML={{
-                  __html: t('audit_log_management.disable_mode_explanation'),
+                  __html: t(
+                    isCloud
+                      ? 'audit_log_management.disable_mode_explanation_cloud'
+                      : 'audit_log_management.disable_mode_explanation',
+                  ),
                 }}
               />
+              {isCloud && (
+                <a
+                  href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+                  className="btn btn-outline-secondary mt-3"
+                >
+                  <span className="material-symbols-outlined me-1">share</span>
+                  {t('cloud_setting_management.to_cloud_settings')}
+                </a>
+              )}
             </div>
           </div>
         </div>

+ 16 - 0
apps/app/src/client/components/Admin/AuditLogManagement.tsx

@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
 import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
 import type { SupportedActionType } from '~/interfaces/activity';
+import { useGrowiAppIdForGrowiCloud, useGrowiCloudUri } from '~/states/global';
 import {
   auditLogAvailableActionsAtom,
   auditLogEnabledAtom,
@@ -35,6 +36,11 @@ const PAGING_LIMIT = 10;
 export const AuditLogManagement: FC = () => {
   const { t } = useTranslation('admin');
 
+  const growiCloudUri = useGrowiCloudUri();
+  const growiAppIdForGrowiCloud = useGrowiAppIdForGrowiCloud();
+
+  const isCloud = growiCloudUri != null && growiAppIdForGrowiCloud != null;
+
   const typeaheadRef = useRef<IClearable>(null);
 
   const auditLogAvailableActionsData = useAtomValue(
@@ -212,6 +218,16 @@ export const AuditLogManagement: FC = () => {
         )}
       </button>
 
+      {isCloud && (
+        <a
+          href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+          className="btn btn-outline-secondary mb-4 ms-2"
+        >
+          <span className="material-symbols-outlined me-1">share</span>
+          {t('cloud_setting_management.to_cloud_settings')}
+        </a>
+      )}
+
       <h2 className="admin-setting-header mb-3">
         <span>
           {isSettingPage

+ 11 - 0
apps/app/src/client/components/Admin/MarkdownSetting/ContentDispositionSettings.tsx

@@ -3,6 +3,8 @@ import { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 
+import { toastError, toastSuccess } from '~/client/util/toastr';
+
 import {
   type ContentDispositionSettings as ContentDispositionSettingsType,
   useContentDisposition,
@@ -136,9 +138,18 @@ const ContentDispositionSettings: React.FC = () => {
     try {
       setError(null);
       await updateSettings(data);
+
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('markdown_settings.content-disposition_header'),
+          ns: 'commons',
+        }),
+      );
+
       reset(data);
     } catch (err) {
       setError((err as Error).message);
+      toastError(err);
     }
   };
 

+ 6 - 9
apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx

@@ -1,5 +1,5 @@
 import { type JSX, useCallback } from 'react';
-import Head from 'next/head';
+import Script from 'next/script';
 import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 
 import { generateViewerMinJsUrl } from './use-viewer-min-js-url';
@@ -40,13 +40,10 @@ export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
   const viewerMinJsSrc = generateViewerMinJsUrl(drawioUri);
 
   return (
-    <Head>
-      <script
-        type="text/javascript"
-        async
-        src={viewerMinJsSrc}
-        onLoad={loadedHandler}
-      />
-    </Head>
+    <Script
+      src={viewerMinJsSrc}
+      strategy="afterInteractive"
+      onLoad={loadedHandler}
+    />
   );
 };

+ 1 - 2
apps/app/src/features/openai/server/services/openai.ts

@@ -13,7 +13,6 @@ import {
 } from '@growi/core';
 import { deepEquals } from '@growi/core/dist/utils';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
-import escapeStringRegexp from 'escape-string-regexp';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import type { OpenAI } from 'openai';
@@ -78,7 +77,7 @@ const convertPathPatternsToRegExp = (
   return pagePathPatterns.map((pagePathPattern) => {
     if (isGlobPatternPath(pagePathPattern)) {
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
-      const escapedPagePathPattern = escapeStringRegexp(trimedPagePathPattern);
+      const escapedPagePathPattern = RegExp.escape(trimedPagePathPattern);
       // https://regex101.com/r/x5KIZL/1
       return new RegExp(`^${escapedPagePathPattern}($|/)`);
     }

+ 9 - 8
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -53,7 +53,12 @@ async function postProcess(
 }
 
 /**
- * Execute a pipeline that reads the page files from the temporal fs directory, compresses them, and uploads to the cloud storage
+ * Compress page files into a tar.gz archive and upload to cloud storage.
+ *
+ * Wraps archiver output with PassThrough to provide a Node.js native Readable,
+ * since archiver uses npm's readable-stream which fails AWS SDK's instanceof check.
+ * The Content-Length / Transfer-Encoding issue is resolved by aws/index.ts using
+ * the Upload class from @aws-sdk/lib-storage.
  */
 export async function compressAndUpload(
   this: IPageBulkExportJobCronService,
@@ -78,12 +83,11 @@ export async function compressAndUpload(
 
   // Wrap with Node.js native PassThrough so that AWS SDK recognizes the stream as a native Readable
   const uploadStream = new PassThrough();
-
-  // Establish pipe before finalize to ensure data flows correctly
   pageArchiver.pipe(uploadStream);
+
   pageArchiver.on('error', (err) => {
+    logger.error('pageArchiver error', err);
     uploadStream.destroy(err);
-    pageArchiver.destroy();
   });
 
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
@@ -100,9 +104,6 @@ export async function compressAndUpload(
     );
   } catch (e) {
     logger.error(e);
-    this.handleError(e, pageBulkExportJob);
-  } finally {
-    pageArchiver.destroy();
-    uploadStream.destroy();
+    await this.handleError(e, pageBulkExportJob);
   }
 }

+ 12 - 0
apps/app/src/interfaces/session-config.ts

@@ -0,0 +1,12 @@
+export interface SessionConfig {
+  rolling: boolean;
+  secret: string;
+  resave: boolean;
+  saveUninitialized: boolean;
+  cookie: {
+    maxAge: number;
+  };
+  genid: (req: { path: string }) => string;
+  name?: string;
+  store?: unknown;
+}

+ 6 - 14
apps/app/src/server/crowi/index.ts

@@ -14,6 +14,7 @@ import { initializeOpenaiService } from '~/features/openai/server/services/opena
 import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
 import instanciatePageBulkExportJobCleanUpCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
 import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
+import type { SessionConfig } from '~/interfaces/session-config';
 import { startCron as startAccessTokenCron } from '~/server/service/access-token';
 import { projectRoot } from '~/server/util/project-dir-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -84,19 +85,6 @@ type CommentServiceType = any;
 type SyncPageStatusServiceType = any;
 type CrowiDevType = any;
 
-interface SessionConfig {
-  rolling: boolean;
-  secret: string;
-  resave: boolean;
-  saveUninitialized: boolean;
-  cookie: {
-    maxAge: number;
-  };
-  genid: (req: { path: string }) => string;
-  name?: string;
-  store?: unknown;
-}
-
 interface CrowiEvents {
   user: UserEvent;
   page: PageEventType;
@@ -588,7 +576,11 @@ class Crowi {
     this.socketIoService.attachServer(httpServer);
 
     // Initialization YjsService
-    initializeYjsService(this.socketIoService.io);
+    initializeYjsService(
+      httpServer,
+      this.socketIoService.io,
+      this.sessionConfig,
+    );
 
     await this.autoInstall();
 

+ 1 - 2
apps/app/src/server/models/obsolete-page.js

@@ -7,7 +7,6 @@ import {
 import { isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import { differenceInYears } from 'date-fns/differenceInYears';
-import escapeStringRegexp from 'escape-string-regexp';
 
 import { Comment } from '~/features/comment/server/models/comment';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
@@ -688,7 +687,7 @@ export const getPageSchema = (crowi) => {
     const regexpList = pathList.map((path) => {
       const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
       return new RegExp(
-        `^${escapeStringRegexp(pathWithTrailingSlash)}_{1,2}template$`,
+        `^${RegExp.escape(pathWithTrailingSlash)}_{1,2}template$`,
       );
     });
 

+ 7 - 8
apps/app/src/server/models/page.ts

@@ -10,7 +10,6 @@ import {
   normalizePath,
 } from '@growi/core/dist/utils/path-utils';
 import assert from 'assert';
-import escapeStringRegexp from 'escape-string-regexp';
 import type mongoose from 'mongoose';
 import type {
   AnyObject,
@@ -348,7 +347,7 @@ export class PageQueryBuilder {
     const pathNormalized = normalizePath(path);
     const pathWithTrailingSlash = addTrailingSlash(path);
 
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+    const startsPattern = RegExp.escape(pathWithTrailingSlash);
 
     this.query = this.query.and({
       $or: [
@@ -373,7 +372,7 @@ export class PageQueryBuilder {
 
     const pathWithTrailingSlash = addTrailingSlash(path);
 
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+    const startsPattern = RegExp.escape(pathWithTrailingSlash);
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
@@ -409,7 +408,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = escapeStringRegexp(path);
+    const startsPattern = RegExp.escape(path);
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
@@ -424,7 +423,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = escapeStringRegexp(str);
+    const startsPattern = RegExp.escape(str);
 
     this.query = this.query.and({
       path: new RegExp(`^(?!${startsPattern}).*$`),
@@ -440,7 +439,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = escapeStringRegexp(path);
+    const startsPattern = RegExp.escape(path);
 
     this.query = this.query.and({
       path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
@@ -455,7 +454,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const match = escapeStringRegexp(str);
+    const match = RegExp.escape(str);
 
     this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
 
@@ -468,7 +467,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const match = escapeStringRegexp(str);
+    const match = RegExp.escape(str);
 
     this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
 

+ 1 - 2
apps/app/src/server/routes/apiv3/users.js

@@ -2,7 +2,6 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
-import escapeStringRegexp from 'escape-string-regexp';
 import express from 'express';
 import { body, query } from 'express-validator';
 import path from 'pathe';
@@ -336,7 +335,7 @@ module.exports = (crowi) => {
 
       // Search from input
       const searchText = req.query.searchText || '';
-      const searchWord = new RegExp(escapeStringRegexp(searchText));
+      const searchWord = new RegExp(RegExp.escape(searchText));
       // Sort
       const { sort, sortOrder } = req.query;
       const sortOutput = {

+ 31 - 20
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -13,6 +13,7 @@ import {
   PutObjectCommand,
   S3Client,
 } from '@aws-sdk/client-s3';
+import { Upload } from '@aws-sdk/lib-storage';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import type { NonBlankString } from '@growi/core/dist/interfaces';
 import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
@@ -252,30 +253,40 @@ class AwsFileUploader extends AbstractFileUploader {
     const filePath = getFilePathOnStorage(attachment);
     const contentHeaders = createContentHeaders(attachment);
 
-    try {
-      const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
+    const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
 
-      await s3.send(
-        new PutObjectCommand({
-          Bucket: getS3Bucket(),
-          Key: filePath,
-          Body: readable,
-          ACL: getS3PutObjectCannedAcl(),
-          // put type and the file name for reference information when uploading
-          ContentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
-          ContentDisposition: getContentHeaderValue(
-            contentHeaders,
-            'Content-Disposition',
-          ),
-        }),
-        { abortSignal: AbortSignal.timeout(uploadTimeout) },
-      );
+    // Use @aws-sdk/lib-storage Upload to handle streaming uploads:
+    // - Resolves archiver's readable-stream (npm) failing AWS SDK's instanceof Readable check
+    // - Avoids Transfer-Encoding: chunked which S3 rejects with 501 (PutObjectCommand issue)
+    // - Under 5MB: falls back to PutObjectCommand internally
+    // - Over 5MB: uses multipart upload (requires s3:AbortMultipartUpload permission)
+    const upload = new Upload({
+      client: s3,
+      params: {
+        Bucket: getS3Bucket(),
+        Key: filePath,
+        Body: readable,
+        ACL: getS3PutObjectCannedAcl(),
+        ContentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+        ContentDisposition: getContentHeaderValue(
+          contentHeaders,
+          'Content-Disposition',
+        ),
+      },
+    });
+
+    const timeoutId = setTimeout(() => {
+      logger.warn(`Upload timeout: fileName=${attachment.fileName}`);
+      upload.abort();
+    }, uploadTimeout);
+
+    try {
+      await upload.done();
 
       logger.debug(
         `File upload completed successfully: fileName=${attachment.fileName}`,
       );
     } catch (error) {
-      // Handle timeout error specifically
       if (error.name === 'AbortError') {
         logger.warn(`Upload timeout: fileName=${attachment.fileName}`, error);
       } else {
@@ -284,9 +295,9 @@ class AwsFileUploader extends AbstractFileUploader {
           error,
         );
       }
-      // Re-throw the error to be handled by the caller.
-      // The pipeline automatically handles stream cleanup on error.
       throw error;
+    } finally {
+      clearTimeout(timeoutId);
     }
   }
 

+ 1 - 5
apps/app/src/server/service/page-grant.ts

@@ -6,7 +6,6 @@ import {
   PageGrant,
 } from '@growi/core';
 import { pagePathUtils, pageUtils, pathUtils } from '@growi/core/dist/utils';
-import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
@@ -590,10 +589,7 @@ class PageGrantService implements IPageGrantService {
     };
 
     const commonCondition = {
-      path: new RegExp(
-        `^${escapeStringRegexp(addTrailingSlash(targetPath))}`,
-        'i',
-      ),
+      path: new RegExp(`^${RegExp.escape(addTrailingSlash(targetPath))}`, 'i'),
       isEmpty: false,
     };
 

+ 9 - 18
apps/app/src/server/service/page/index.ts

@@ -18,7 +18,6 @@ import type {
 } from '@growi/core/dist/interfaces';
 import { PageGrant } from '@growi/core/dist/interfaces';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
-import escapeStringRegexp from 'escape-string-regexp';
 import type EventEmitter from 'events';
 import type { Cursor, HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
@@ -941,7 +940,7 @@ class PageService implements IPageService {
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
-    const pathToTest = escapeStringRegexp(addTrailingSlash(fromPath));
+    const pathToTest = RegExp.escape(addTrailingSlash(fromPath));
     const pathToBeTested = toPath;
 
     return new RegExp(`^${pathToTest}`, 'i').test(pathToBeTested);
@@ -1245,10 +1244,7 @@ class PageService implements IPageService {
     const batchStream = createBatchStream(BULK_REINDEX_SIZE);
 
     const newPagePathPrefix = newPagePathSanitized;
-    const pathRegExp = new RegExp(
-      `^${escapeStringRegexp(targetPage.path)}`,
-      'i',
-    );
+    const pathRegExp = new RegExp(`^${RegExp.escape(targetPage.path)}`, 'i');
 
     const renameDescendants = this.renameDescendants.bind(this);
     const pageEvent = this.pageEvent;
@@ -1304,10 +1300,7 @@ class PageService implements IPageService {
     const batchStream = createBatchStream(BULK_REINDEX_SIZE);
 
     const newPagePathPrefix = newPagePathSanitized;
-    const pathRegExp = new RegExp(
-      `^${escapeStringRegexp(targetPage.path)}`,
-      'i',
-    );
+    const pathRegExp = new RegExp(`^${RegExp.escape(targetPage.path)}`, 'i');
 
     const renameDescendants = this.renameDescendants.bind(this);
     const pageEvent = this.pageEvent;
@@ -1892,7 +1885,7 @@ class PageService implements IPageService {
     const batchStream = createBatchStream(BULK_REINDEX_SIZE);
 
     const newPagePathPrefix = newPagePathSanitized;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+    const pathRegExp = new RegExp(`^${RegExp.escape(page.path)}`, 'i');
 
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const pageEvent = this.pageEvent;
@@ -1948,7 +1941,7 @@ class PageService implements IPageService {
     const batchStream = createBatchStream(BULK_REINDEX_SIZE);
 
     const newPagePathPrefix = newPagePathSanitized;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+    const pathRegExp = new RegExp(`^${RegExp.escape(page.path)}`, 'i');
 
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const pageEvent = this.pageEvent;
@@ -3968,7 +3961,7 @@ class PageService implements IPageService {
     const ancestorPaths = paths.flatMap((p) => collectAncestorPaths(p, []));
     // targets' descendants
     const pathAndRegExpsToNormalize: (RegExp | string)[] = paths.map(
-      (p) => new RegExp(`^${escapeStringRegexp(addTrailingSlash(p))}`, 'i'),
+      (p) => new RegExp(`^${RegExp.escape(addTrailingSlash(p))}`, 'i'),
     );
     // include targets' path
     pathAndRegExpsToNormalize.push(...paths);
@@ -4179,7 +4172,7 @@ class PageService implements IPageService {
           const parentId = parent._id;
 
           // Build filter
-          const parentPathEscaped = escapeStringRegexp(
+          const parentPathEscaped = RegExp.escape(
             parent.path === '/' ? '' : parent.path,
           ); // adjust the path for RegExp
           const filter: any = {
@@ -5148,9 +5141,7 @@ class PageService implements IPageService {
     const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
     const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
-      path: new RegExp(
-        `^${escapeStringRegexp(addTrailingSlash(currentPage.path))}`,
-      ),
+      path: new RegExp(`^${RegExp.escape(addTrailingSlash(currentPage.path))}`),
       parent: { $ne: null },
     });
 
@@ -5282,7 +5273,7 @@ class PageService implements IPageService {
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
       path: new RegExp(
-        `^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`,
+        `^${RegExp.escape(addTrailingSlash(clonedPageData.path))}`,
       ),
       parent: { $ne: null },
     });

+ 3 - 0
apps/app/src/server/service/socket-io/socket-io.ts

@@ -43,6 +43,9 @@ export class SocketIoService {
   async attachServer(server) {
     this.io = new Server(server, {
       serveClient: false,
+      // Allow non-Socket.IO WebSocket upgrade requests (e.g. /yjs/) to pass through
+      // without being destroyed by engine.io's default timeout handler
+      destroyUpgrade: false,
     });
 
     // create namespace for admin

+ 61 - 20
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -1,23 +1,38 @@
-import type { Persistence } from 'y-socket.io/dist/server';
+import { YDocStatus } from '@growi/core/dist/consts';
+import type { Server } from 'socket.io';
+import type { WSSharedDoc, YWebsocketPersistence } from 'y-websocket/bin/utils';
 import * as Y from 'yjs';
 
+import { SocketEventName } from '~/interfaces/websocket';
+import {
+  getRoomNameWithId,
+  RoomPrefix,
+} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 
 import type { MongodbPersistence } from './extended/mongodb-persistence';
+import type { syncYDoc as syncYDocType } from './sync-ydoc';
 
 const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
 
+type GetYDocStatus = (pageId: string) => Promise<YDocStatus>;
+
 /**
- * Based on the example by https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#an-other-example
- * @param mdb
- * @returns
+ * Creates a y-websocket compatible persistence layer backed by MongoDB.
+ *
+ * bindState also handles:
+ * - sync-on-load (syncYDoc) after persisted state is applied
+ * - awareness event bridge to Socket.IO rooms
  */
 export const createMongoDBPersistence = (
   mdb: MongodbPersistence,
-): Persistence => {
-  const persistece: Persistence = {
+  io: Server,
+  syncYDoc: typeof syncYDocType,
+  getYDocStatus: GetYDocStatus,
+): YWebsocketPersistence => {
+  const persistence: YWebsocketPersistence = {
     provider: mdb,
-    bindState: async (docName, ydoc) => {
+    bindState: async (docName: string, ydoc: WSSharedDoc) => {
       logger.debug('bindState', { docName });
 
       const persistedYdoc = await mdb.getYDoc(docName);
@@ -27,12 +42,7 @@ export const createMongoDBPersistence = (
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
 
       // store the new data in db (if there is any: empty update is an array of 0s)
-      if (
-        diff.reduce(
-          (previousValue, currentValue) => previousValue + currentValue,
-          0,
-        ) > 0
-      ) {
+      if (diff.some((b) => b !== 0)) {
         mdb.storeUpdate(docName, diff);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       }
@@ -40,23 +50,54 @@ export const createMongoDBPersistence = (
       // send the persisted data to clients
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
 
+      // cleanup some memory
+      persistedYdoc.destroy();
+
+      // sync with the latest revision after persisted state is applied
+      const ydocStatus = await getYDocStatus(docName);
+      syncYDoc(mdb, ydoc, { ydocStatus });
+
       // store updates of the document in db
-      ydoc.on('update', async (update) => {
+      ydoc.on('update', (update: Uint8Array) => {
         mdb.storeUpdate(docName, update);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       });
 
-      // cleanup some memory
-      persistedYdoc.destroy();
+      // register awareness event bridge to Socket.IO rooms
+      // Only emit when the awareness state size actually changes (cursor moves
+      // and other updates fire frequently but don't change the user count)
+      let lastEmittedSize = -1;
+      ydoc.awareness.on('update', async () => {
+        const pageId = docName;
+        const awarenessStateSize = ydoc.awareness.getStates().size;
+
+        if (awarenessStateSize !== lastEmittedSize) {
+          lastEmittedSize = awarenessStateSize;
+          io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+            SocketEventName.YjsAwarenessStateSizeUpdated,
+            awarenessStateSize,
+          );
+        }
+
+        // emit draft status when last user leaves
+        if (awarenessStateSize === 0) {
+          const status = await getYDocStatus(pageId);
+          const hasYdocsNewerThanLatestRevision =
+            status === YDocStatus.DRAFT || status === YDocStatus.ISOLATED;
+
+          io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+            SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+            hasYdocsNewerThanLatestRevision,
+          );
+        }
+      });
     },
-    writeState: async (docName) => {
+    writeState: async (docName: string) => {
       logger.debug('writeState', { docName });
-      // This is called when all connections to the document are closed.
-
       // flush document on close to have the smallest possible database
       await mdb.flushDocument(docName);
     },
   };
 
-  return persistece;
+  return persistence;
 };

+ 159 - 0
apps/app/src/server/service/yjs/guard-socket.spec.ts

@@ -0,0 +1,159 @@
+import http from 'node:http';
+import WebSocket, { WebSocketServer } from 'ws';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
+
+import { guardSocket } from './guard-socket';
+
+/**
+ * Creates a test server where:
+ * 1. The Yjs upgrade handler guards the socket and awaits before completing
+ * 2. A hostile handler (simulating Next.js) calls socket.end() for /yjs/ paths
+ */
+const createServerWithHostileHandler = (): {
+  server: http.Server;
+  wss: WebSocketServer;
+} => {
+  const server = http.createServer();
+  const wss = new WebSocketServer({ noServer: true });
+
+  // Yjs handler (registered first — same order as production)
+  server.on('upgrade', async (request, socket, head) => {
+    const url = request.url ?? '';
+    if (!url.startsWith('/yjs/')) return;
+
+    const pageId = url.slice('/yjs/'.length).split('?')[0];
+
+    const guard = guardSocket(socket);
+
+    try {
+      // Simulate async auth delay
+      await new Promise((resolve) => setTimeout(resolve, 10));
+
+      guard.restore();
+
+      wss.handleUpgrade(request, socket, head, (ws) => {
+        wss.emit('connection', ws, request);
+        setupWSConnection(ws, request, { docName: pageId });
+      });
+    } catch {
+      guard.restore();
+      socket.destroy();
+    }
+  });
+
+  // Hostile handler (registered second — simulates Next.js upgradeHandler)
+  server.on('upgrade', (_request, socket) => {
+    socket.end();
+  });
+
+  return { server, wss };
+};
+
+const connectClient = (port: number, pageId: string): Promise<WebSocket> => {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`ws://127.0.0.1:${port}/yjs/${pageId}`);
+    ws.binaryType = 'arraybuffer';
+    ws.on('open', () => resolve(ws));
+    ws.on('error', reject);
+  });
+};
+
+describe('guardSocket — protection against hostile upgrade handlers', () => {
+  let server: http.Server;
+  let wss: WebSocketServer;
+  let port: number;
+
+  beforeAll(async () => {
+    setPersistence(null);
+
+    const testServer = createServerWithHostileHandler();
+    server = testServer.server;
+    wss = testServer.wss;
+
+    await new Promise<void>((resolve) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (addr && typeof addr === 'object') {
+          port = addr.port;
+        }
+        resolve();
+      });
+    });
+  });
+
+  afterAll(async () => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+
+    await new Promise<void>((resolve) => {
+      wss.close(() => {
+        server.close(() => resolve());
+      });
+    });
+  });
+
+  afterEach(() => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+  });
+
+  it('should establish WebSocket connection even when a hostile handler calls socket.end()', async () => {
+    const pageId = 'guard-test-001';
+
+    const ws = await connectClient(port, pageId);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const serverDoc = docs.get(pageId);
+    expect(serverDoc).toBeDefined();
+    assert(serverDoc !== undefined);
+    expect(serverDoc.conns.size).toBe(1);
+
+    ws.close();
+  });
+
+  it('should handle multiple concurrent connections with hostile handler', async () => {
+    const pageId = 'guard-test-002';
+
+    const connections = await Promise.all([
+      connectClient(port, pageId),
+      connectClient(port, pageId),
+    ]);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const serverDoc = docs.get(pageId);
+    expect(serverDoc).toBeDefined();
+    assert(serverDoc !== undefined);
+    expect(serverDoc.conns.size).toBe(2);
+
+    for (const ws of connections) {
+      ws.close();
+    }
+  });
+
+  it('should allow normal close after guard is restored', async () => {
+    const pageId = 'guard-test-003';
+
+    const ws = await connectClient(port, pageId);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    // Connection succeeds, meaning socket.end/destroy were properly
+    // guarded during async auth and restored before wss.handleUpgrade
+    expect(ws.readyState).toBe(WebSocket.OPEN);
+
+    ws.close();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    // After close, the server doc should have removed the connection
+    const serverDoc = docs.get(pageId);
+    if (serverDoc) {
+      expect(serverDoc.conns.size).toBe(0);
+    }
+  });
+});

+ 30 - 0
apps/app/src/server/service/yjs/guard-socket.ts

@@ -0,0 +1,30 @@
+import type { Duplex } from 'node:stream';
+
+type SocketGuard = {
+  restore: () => void;
+};
+
+/**
+ * Temporarily replaces socket.end() and socket.destroy() with no-ops.
+ *
+ * This prevents other synchronous `upgrade` event listeners (e.g. Next.js's
+ * NextCustomServer.upgradeHandler) from closing the socket while an async
+ * handler is awaiting authentication.
+ *
+ * Call `restore()` on the returned object to reinstate the original methods
+ * before performing the actual WebSocket handshake or cleanup.
+ */
+export const guardSocket = (socket: Duplex): SocketGuard => {
+  const origEnd = socket.end.bind(socket);
+  const origDestroy = socket.destroy.bind(socket);
+
+  socket.end = () => socket;
+  socket.destroy = () => socket;
+
+  return {
+    restore: () => {
+      socket.end = origEnd;
+      socket.destroy = origDestroy;
+    },
+  };
+};

+ 2 - 2
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -1,6 +1,6 @@
 import { Origin, YDocStatus } from '@growi/core';
 import type { Delta } from '@growi/editor';
-import type { Document } from 'y-socket.io/dist/server';
+import type { WSSharedDoc } from 'y-websocket/bin/utils';
 
 import loggerFactory from '~/utils/logger';
 
@@ -22,7 +22,7 @@ type Context = {
  */
 export const syncYDoc = async (
   mdb: MongodbPersistence,
-  doc: Document,
+  doc: WSSharedDoc,
   context: true | Context,
 ): Promise<void> => {
   const pageId = doc.name;

+ 177 - 0
apps/app/src/server/service/yjs/upgrade-handler.spec.ts

@@ -0,0 +1,177 @@
+import type { IncomingMessage } from 'node:http';
+import type { Duplex } from 'node:stream';
+import type { IUserHasId } from '@growi/core';
+import { mock } from 'vitest-mock-extended';
+
+import { createUpgradeHandler } from './upgrade-handler';
+
+type AuthenticatedIncomingMessage = IncomingMessage & { user?: IUserHasId };
+
+interface MockSocket {
+  write: ReturnType<typeof vi.fn>;
+  destroy: ReturnType<typeof vi.fn>;
+}
+
+const { isAccessibleMock } = vi.hoisted(() => ({
+  isAccessibleMock: vi.fn(),
+}));
+
+vi.mock('mongoose', () => ({
+  default: {
+    model: () => ({ isAccessiblePageByViewer: isAccessibleMock }),
+  },
+}));
+
+const { sessionMiddlewareMock } = vi.hoisted(() => ({
+  sessionMiddlewareMock: vi.fn(
+    (_req: unknown, _res: unknown, next: () => void) => next(),
+  ),
+}));
+
+vi.mock('express-session', () => ({
+  default: () => sessionMiddlewareMock,
+}));
+
+vi.mock('passport', () => ({
+  default: {
+    initialize: () => (_req: unknown, _res: unknown, next: () => void) =>
+      next(),
+    session: () => (_req: unknown, _res: unknown, next: () => void) => next(),
+  },
+}));
+
+const sessionConfig = {
+  rolling: true,
+  secret: 'test-secret',
+  resave: false,
+  saveUninitialized: true,
+  cookie: { maxAge: 86400000 },
+  genid: () => 'test-session-id',
+};
+
+const createMockRequest = (
+  url: string,
+  user?: IUserHasId,
+): AuthenticatedIncomingMessage => {
+  const req = mock<AuthenticatedIncomingMessage>();
+  req.url = url;
+  req.headers = { cookie: 'connect.sid=test-session' };
+  req.user = user;
+  return req;
+};
+
+const createMockSocket = (): Duplex & MockSocket => {
+  return {
+    write: vi.fn().mockReturnValue(true),
+    destroy: vi.fn(),
+  } as unknown as Duplex & MockSocket;
+};
+
+describe('UpgradeHandler', () => {
+  const handleUpgrade = createUpgradeHandler(sessionConfig);
+
+  it('should authorize a valid user with page access', async () => {
+    isAccessibleMock.mockResolvedValue(true);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011', {
+      _id: 'user1',
+      name: 'Test User',
+    } as unknown as IUserHasId);
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(true);
+    if (result.authorized) {
+      expect(result.pageId).toBe('507f1f77bcf86cd799439011');
+    }
+  });
+
+  it('should reject with 400 for missing/malformed URL path', async () => {
+    const request = createMockRequest('/invalid/path');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(400);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('400'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should reject with 403 when user has no page access', async () => {
+    isAccessibleMock.mockResolvedValue(false);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011', {
+      _id: 'user1',
+      name: 'Test User',
+    } as unknown as IUserHasId);
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(403);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('403'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should reject with 401 when unauthenticated user has no page access', async () => {
+    isAccessibleMock.mockResolvedValue(false);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(401);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should allow guest user when page allows guest access', async () => {
+    isAccessibleMock.mockResolvedValue(true);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(true);
+    if (result.authorized) {
+      expect(result.pageId).toBe('507f1f77bcf86cd799439011');
+    }
+  });
+
+  it('should reject with 401 when session middleware fails', async () => {
+    sessionMiddlewareMock.mockImplementationOnce(
+      (_req: unknown, _res: unknown, next: (err?: unknown) => void) =>
+        next(new Error('session store unavailable')),
+    );
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(401);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+});

+ 131 - 0
apps/app/src/server/service/yjs/upgrade-handler.ts

@@ -0,0 +1,131 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
+import expressSession from 'express-session';
+import type { IncomingMessage, ServerResponse } from 'http';
+import mongoose from 'mongoose';
+import passport from 'passport';
+import type { Duplex } from 'stream';
+
+import type { SessionConfig } from '~/interfaces/session-config';
+import loggerFactory from '~/utils/logger';
+
+import type { PageModel } from '../../models/page';
+
+const logger = loggerFactory('growi:service:yjs:upgrade-handler');
+
+type AuthenticatedRequest = IncomingMessage & {
+  user?: IUserHasId;
+};
+
+/**
+ * Connect-style middleware that operates on raw Node.js HTTP types.
+ * Express middleware (express-session, passport) is compatible because
+ * express.Request extends IncomingMessage and express.Response extends ServerResponse.
+ */
+type ConnectMiddleware = (
+  req: IncomingMessage,
+  res: ServerResponse,
+  next: (err?: unknown) => void,
+) => void;
+
+/**
+ * Run a Connect-style middleware against a raw IncomingMessage.
+ * Safe for express-session, passport.initialize(), and passport.session() which
+ * only read/write `req` properties and call `next()` — they never write to `res`.
+ */
+const runMiddleware = (
+  middleware: ConnectMiddleware,
+  req: IncomingMessage,
+): Promise<void> =>
+  new Promise((resolve, reject) => {
+    const stubRes = {} as ServerResponse;
+    middleware(req, stubRes, (err?: unknown) => {
+      if (err) return reject(err);
+      resolve();
+    });
+  });
+
+/**
+ * Extracts pageId from upgrade request URL.
+ * Expected format: /yjs/{pageId}
+ */
+const pageIdPattern = new RegExp(`^${YJS_WEBSOCKET_BASE_PATH}/([a-f0-9]{24})`);
+const extractPageId = (url: string | undefined): string | null => {
+  if (url == null) return null;
+  const match = url.match(pageIdPattern);
+  return match?.[1] ?? null;
+};
+
+/**
+ * Writes an HTTP error response to the socket.
+ * Does NOT close the socket — the caller (yjs.ts) manages socket lifecycle
+ * so that guardSocket can safely intercept end/destroy during async auth.
+ */
+const writeErrorResponse = (
+  socket: Duplex,
+  statusCode: number,
+  message: string,
+): void => {
+  socket.write(`HTTP/1.1 ${statusCode} ${message}\r\n\r\n`);
+};
+
+export type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+
+/**
+ * Creates an upgrade handler that authenticates WebSocket connections
+ * using the existing express-session + passport mechanism.
+ */
+export const createUpgradeHandler = (sessionConfig: SessionConfig) => {
+  const sessionMiddleware = expressSession(sessionConfig as any);
+  const passportInit = passport.initialize();
+  const passportSession = passport.session();
+
+  return async (
+    request: IncomingMessage,
+    socket: Duplex,
+    _head: Buffer,
+  ): Promise<UpgradeResult> => {
+    const pageId = extractPageId(request.url);
+    if (pageId == null) {
+      logger.warn('Invalid URL path for Yjs upgrade', { url: request.url });
+      writeErrorResponse(socket, 400, 'Bad Request');
+      return { authorized: false, statusCode: 400 };
+    }
+
+    try {
+      // Run session + passport middleware chain
+      await runMiddleware(sessionMiddleware as ConnectMiddleware, request);
+      await runMiddleware(passportInit as ConnectMiddleware, request);
+      await runMiddleware(passportSession as ConnectMiddleware, request);
+    } catch (err) {
+      logger.warn('Session/passport middleware failed on upgrade', { err });
+      writeErrorResponse(socket, 401, 'Unauthorized');
+      return { authorized: false, statusCode: 401 };
+    }
+
+    const user = (request as AuthenticatedRequest).user ?? null;
+
+    // Check page access
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
+
+    if (!isAccessible) {
+      const statusCode = user == null ? 401 : 403;
+      const message = user == null ? 'Unauthorized' : 'Forbidden';
+      logger.warn(`Yjs upgrade rejected: ${message}`, {
+        pageId,
+        userId: user?._id,
+      });
+      writeErrorResponse(socket, statusCode, message);
+      return { authorized: false, statusCode };
+    }
+
+    return {
+      authorized: true,
+      request: request as AuthenticatedRequest,
+      pageId,
+    };
+  };
+};

+ 205 - 0
apps/app/src/server/service/yjs/websocket-connection.integ.ts

@@ -0,0 +1,205 @@
+import http from 'node:http';
+import WebSocket, { WebSocketServer } from 'ws';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
+
+/**
+ * Creates a minimal HTTP + y-websocket server for testing.
+ * No authentication — pure document sync testing.
+ */
+const createTestServer = (): { server: http.Server; wss: WebSocketServer } => {
+  const server = http.createServer();
+  const wss = new WebSocketServer({ noServer: true });
+
+  server.on('upgrade', (request, socket, head) => {
+    const url = request.url ?? '';
+    if (!url.startsWith('/yjs/')) return;
+    const pageId = url.slice('/yjs/'.length).split('?')[0];
+
+    wss.handleUpgrade(request, socket, head, (ws) => {
+      wss.emit('connection', ws, request);
+      setupWSConnection(ws, request, { docName: pageId });
+    });
+  });
+
+  return { server, wss };
+};
+
+/**
+ * Connects a WebSocket client and waits for the connection to open.
+ */
+const connectClient = (port: number, pageId: string): Promise<WebSocket> => {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`ws://127.0.0.1:${port}/yjs/${pageId}`);
+    ws.binaryType = 'arraybuffer';
+    ws.on('open', () => resolve(ws));
+    ws.on('error', reject);
+  });
+};
+
+/**
+ * Waits for a WebSocket to fully close.
+ */
+const waitForClose = (ws: WebSocket): Promise<void> => {
+  return new Promise((resolve) => {
+    if (ws.readyState === WebSocket.CLOSED) return resolve();
+    ws.on('close', () => resolve());
+  });
+};
+
+describe('WebSocket Connection and Sync Flow', () => {
+  let server: http.Server;
+  let wss: WebSocketServer;
+  let port: number;
+
+  beforeAll(async () => {
+    setPersistence(null);
+
+    const testServer = createTestServer();
+    server = testServer.server;
+    wss = testServer.wss;
+
+    await new Promise<void>((resolve) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (addr && typeof addr === 'object') {
+          port = addr.port;
+        }
+        resolve();
+      });
+    });
+  });
+
+  afterAll(async () => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+
+    await new Promise<void>((resolve) => {
+      wss.close(() => {
+        server.close(() => resolve());
+      });
+    });
+  });
+
+  afterEach(() => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+  });
+
+  describe('Connection and sync flow', () => {
+    it('should create a server-side Y.Doc on first client connection', async () => {
+      const pageId = 'test-page-sync-001';
+
+      const ws = await connectClient(port, pageId);
+
+      // Wait for setupWSConnection to register the doc
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.name).toBe(pageId);
+      expect(serverDoc.conns.size).toBe(1);
+
+      ws.close();
+    });
+
+    it('should register multiple clients on the same server-side Y.Doc', async () => {
+      const pageId = 'test-page-multi-001';
+
+      const ws1 = await connectClient(port, pageId);
+      const ws2 = await connectClient(port, pageId);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(2);
+
+      ws1.close();
+      ws2.close();
+    });
+
+    it('should keep the server doc alive when one client disconnects', async () => {
+      const pageId = 'test-page-reconnect-001';
+
+      const ws1 = await connectClient(port, pageId);
+      const ws2 = await connectClient(port, pageId);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Disconnect client 1
+      ws1.close();
+      await waitForClose(ws1);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Server doc should still exist with client 2
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(1);
+
+      ws2.close();
+    });
+  });
+
+  describe('Concurrency — single Y.Doc per page', () => {
+    it('should create exactly one Y.Doc for simultaneous connections', async () => {
+      const pageId = 'test-page-concurrent-001';
+
+      // Connect multiple clients simultaneously
+      const connections = await Promise.all([
+        connectClient(port, pageId),
+        connectClient(port, pageId),
+        connectClient(port, pageId),
+      ]);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Verify single Y.Doc instance
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(3);
+
+      // Only one doc for this page
+      const matchingDocs = Array.from(docs.values()).filter(
+        (d) => d.name === pageId,
+      );
+      expect(matchingDocs).toHaveLength(1);
+
+      for (const ws of connections) {
+        ws.close();
+      }
+    });
+
+    it('should handle disconnect during connect without document corruption', async () => {
+      const pageId = 'test-page-disconnect-001';
+
+      // Client 1 connects
+      const ws1 = await connectClient(port, pageId);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Write to server doc directly
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      serverDoc.getText('codemirror').insert(0, 'Hello World');
+
+      // Client 2 connects and immediately disconnects
+      const ws2 = await connectClient(port, pageId);
+      ws2.close();
+      await waitForClose(ws2);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Server doc should still exist with client 1
+      const docAfter = docs.get(pageId);
+      assert(docAfter !== undefined);
+      expect(docAfter.conns.size).toBe(1);
+
+      // Text should be intact
+      expect(docAfter.getText('codemirror').toString()).toBe('Hello World');
+
+      ws1.close();
+    });
+  });
+});

+ 39 - 0
apps/app/src/server/service/yjs/y-websocket-server.d.ts

@@ -0,0 +1,39 @@
+declare module 'y-websocket/bin/utils' {
+  import type { IncomingMessage } from 'http';
+  import type { WebSocket } from 'ws';
+  import type { Awareness } from 'y-protocols/awareness';
+  import * as Y from 'yjs';
+
+  export class WSSharedDoc extends Y.Doc {
+    name: string;
+    conns: Map<WebSocket, Set<number>>;
+    awareness: Awareness;
+    whenInitialized: Promise<void>;
+    constructor(name: string);
+  }
+
+  export interface YWebsocketPersistence {
+    bindState: (docName: string, ydoc: WSSharedDoc) => void;
+    writeState: (docName: string, ydoc: WSSharedDoc) => Promise<void>;
+    provider: unknown;
+  }
+
+  export function setPersistence(
+    persistence: YWebsocketPersistence | null,
+  ): void;
+  export function getPersistence(): YWebsocketPersistence | null;
+
+  export const docs: Map<string, WSSharedDoc>;
+
+  export function getYDoc(docname: string, gc?: boolean): WSSharedDoc;
+
+  export function setupWSConnection(
+    conn: WebSocket,
+    req: IncomingMessage,
+    opts?: { docName?: string; gc?: boolean },
+  ): void;
+
+  export function setContentInitializor(
+    f: (ydoc: Y.Doc) => Promise<void>,
+  ): void;
+}

+ 25 - 10
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -1,3 +1,4 @@
+import http from 'node:http';
 import { YDocStatus } from '@growi/core/dist/consts';
 import { Types } from 'mongoose';
 import type { Server } from 'socket.io';
@@ -8,11 +9,15 @@ import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { IYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 
-vi.mock('y-socket.io/dist/server', () => {
-  const YSocketIO = vi.fn();
-  YSocketIO.prototype.on = vi.fn();
-  YSocketIO.prototype.initialize = vi.fn();
-  return { YSocketIO };
+vi.mock('y-websocket/bin/utils', () => {
+  const docs = new Map();
+  return {
+    docs,
+    setPersistence: vi.fn(),
+    setupWSConnection: vi.fn(),
+    getYDoc: vi.fn(),
+    setContentInitializor: vi.fn(),
+  };
 });
 
 vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
@@ -30,16 +35,25 @@ describe('YjsService', () => {
   describe('getYDocStatus()', () => {
     beforeAll(() => {
       const ioMock = mock<Server>();
+      const httpServer = http.createServer();
+      const sessionConfig = {
+        rolling: true,
+        secret: 'test-secret',
+        resave: false,
+        saveUninitialized: true,
+        cookie: { maxAge: 86400000 },
+        genid: () => 'test-session-id',
+      };
 
       // initialize
-      initializeYjsService(ioMock);
+      initializeYjsService(httpServer, ioMock, sessionConfig);
     });
 
-    afterAll(async () => {
-      // flush revisions
+    afterEach(async () => {
       await Revision.deleteMany({});
+    });
 
-      // flush yjs-writings
+    afterAll(async () => {
       const yjsService = getYjsService();
       const privateMdb = getPrivateMdbInstance(yjsService);
       try {
@@ -48,7 +62,8 @@ describe('YjsService', () => {
         // Ignore errors that can occur due to async index creation:
         // - 26: NamespaceNotFound (collection not yet created)
         // - 276: IndexBuildAborted (cleanup during index creation)
-        if (error.code !== 26 && error.code !== 276) {
+        const code = (error as { code?: number }).code;
+        if (code !== 26 && code !== 276) {
           throw error;
         }
       }

+ 72 - 93
apps/app/src/server/service/yjs/yjs.ts

@@ -1,49 +1,47 @@
-import type { IPage, IUserHasId } from '@growi/core';
-import { YDocStatus } from '@growi/core/dist/consts';
-import type { IncomingMessage } from 'http';
+import type http from 'node:http';
+import { YDocStatus, YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import mongoose from 'mongoose';
 import type { Server } from 'socket.io';
-import type { Document } from 'y-socket.io/dist/server';
-import { type Document as Ydoc, YSocketIO } from 'y-socket.io/dist/server';
+import { WebSocketServer } from 'ws';
+import type { WSSharedDoc } from 'y-websocket/bin/utils';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
 
-import { SocketEventName } from '~/interfaces/websocket';
+import type { SessionConfig } from '~/interfaces/session-config';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
-import {
-  getRoomNameWithId,
-  RoomPrefix,
-} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 
-import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
+import { guardSocket } from './guard-socket';
 import { syncYDoc } from './sync-ydoc';
+import { createUpgradeHandler } from './upgrade-handler';
 
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+const YJS_PATH_PREFIX = `${YJS_WEBSOCKET_BASE_PATH}/`;
 
 const logger = loggerFactory('growi:service:yjs');
 
-type RequestWithUser = IncomingMessage & { user: IUserHasId };
-
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
   syncWithTheLatestRevisionForce(
     pageId: string,
     editingMarkdownLength?: number,
   ): Promise<SyncLatestRevisionBody>;
-  getCurrentYdoc(pageId: string): Ydoc | undefined;
+  getCurrentYdoc(pageId: string): WSSharedDoc | undefined;
 }
 
 class YjsService implements IYjsService {
-  private ysocketio: YSocketIO;
-
   private mdb: MongodbPersistence;
 
-  constructor(io: Server) {
+  constructor(
+    httpServer: http.Server,
+    io: Server,
+    sessionConfig: SessionConfig,
+  ) {
     const mdb = new MongodbPersistence(
       {
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
@@ -57,80 +55,62 @@ class YjsService implements IYjsService {
     );
     this.mdb = mdb;
 
-    // initialize YSocketIO
-    const ysocketio = new YSocketIO(io);
-    this.injectPersistence(ysocketio, mdb);
-    ysocketio.initialize();
-    this.ysocketio = ysocketio;
-
     // create indexes
     createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
 
-    // register middlewares
-    this.registerAccessiblePageChecker(ysocketio);
-
-    ysocketio.on('document-loaded', async (doc: Document) => {
-      const pageId = doc.name;
-
-      const ydocStatus = await this.getYDocStatus(pageId);
-
-      syncYDoc(mdb, doc, { ydocStatus });
-    });
-
-    ysocketio.on('awareness-update', async (doc: Document) => {
-      const pageId = doc.name;
-
-      if (pageId == null) return;
-
-      const awarenessStateSize = doc.awareness.states.size;
+    // setup y-websocket persistence (includes awareness bridge and sync-on-load)
+    const persistence = createMongoDBPersistence(mdb, io, syncYDoc, (pageId) =>
+      this.getYDocStatus(pageId),
+    );
+    setPersistence(persistence);
 
-      // Triggered when awareness changes
-      io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
-        SocketEventName.YjsAwarenessStateSizeUpdated,
-        awarenessStateSize,
-      );
+    // setup WebSocket server
+    const wss = new WebSocketServer({ noServer: true });
+    const handleUpgrade = createUpgradeHandler(sessionConfig);
 
-      // Triggered when the last user leaves the editor
-      if (awarenessStateSize === 0) {
-        const ydocStatus = await this.getYDocStatus(pageId);
-        const hasYdocsNewerThanLatestRevision =
-          ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+    httpServer.on('upgrade', async (request, socket, head) => {
+      const url = request.url ?? '';
 
-        io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
-          SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
-          hasYdocsNewerThanLatestRevision,
-        );
+      // Only handle /yjs/ paths; let Socket.IO and others pass through
+      if (!url.startsWith(YJS_PATH_PREFIX)) {
+        return;
       }
-    });
-  }
-
-  private injectPersistence(
-    ysocketio: YSocketIO,
-    mdb: MongodbPersistence,
-  ): void {
-    const persistece = createMongoDBPersistence(mdb);
-
-    // foce set to private property
-    // biome-ignore lint/complexity/useLiteralKeys: ignore
-    ysocketio['persistence'] = persistece;
-  }
-
-  private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
-    // check accessible page
-    ysocketio.nsp?.use(async (socket, next) => {
-      // extract page id from namespace
-      const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
-      const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
 
-      const Page = mongoose.model<IPage, PageModel>('Page');
-      const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
-
-      if (!isAccessible) {
-        return next(new Error('Forbidden'));
+      // Guard the socket against being closed by other upgrade handlers
+      // (e.g. Next.js's NextCustomServer.upgradeHandler) that run synchronously
+      // after this async handler yields at the first await.
+      const guard = guardSocket(socket);
+
+      try {
+        const result = await handleUpgrade(request, socket, head);
+
+        // Restore original socket methods now that all synchronous
+        // upgrade handlers have finished
+        guard.restore();
+
+        if (!result.authorized) {
+          // rejectUpgrade already wrote the HTTP error response but
+          // socket.destroy() was a no-op during the guard; clean up now
+          socket.destroy();
+          return;
+        }
+
+        wss.handleUpgrade(result.request, socket, head, (ws) => {
+          wss.emit('connection', ws, result.request);
+          setupWSConnection(ws, result.request, { docName: result.pageId });
+        });
+      } catch (err) {
+        guard.restore();
+
+        logger.error('Yjs upgrade handler failed unexpectedly', { url, err });
+        if (socket.writable) {
+          socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
+        }
+        socket.destroy();
       }
-
-      return next();
     });
+
+    logger.info('YjsService initialized with y-websocket');
   }
 
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
@@ -187,14 +167,14 @@ class YjsService implements IYjsService {
     pageId: string,
     editingMarkdownLength?: number,
   ): Promise<SyncLatestRevisionBody> {
-    const doc = this.ysocketio.documents.get(pageId);
+    const doc = docs.get(pageId);
 
     if (doc == null) {
       return { synced: false };
     }
 
-    const ytextLength = doc?.getText('codemirror').length;
-    syncYDoc(this.mdb, doc, true);
+    const ytextLength = doc.getText('codemirror').length;
+    await syncYDoc(this.mdb, doc, true);
 
     return {
       synced: true,
@@ -205,24 +185,23 @@ class YjsService implements IYjsService {
     };
   }
 
-  public getCurrentYdoc(pageId: string): Ydoc | undefined {
-    const currentYdoc = this.ysocketio.documents.get(pageId);
-    return currentYdoc;
+  public getCurrentYdoc(pageId: string): WSSharedDoc | undefined {
+    return docs.get(pageId);
   }
 }
 
 let _instance: YjsService;
 
-export const initializeYjsService = (io: Server): void => {
+export const initializeYjsService = (
+  httpServer: http.Server,
+  io: Server,
+  sessionConfig: SessionConfig,
+): void => {
   if (_instance != null) {
     throw new Error('YjsService is already initialized');
   }
 
-  if (io == null) {
-    throw new Error("'io' is required if initialize YjsService");
-  }
-
-  _instance = new YjsService(io);
+  _instance = new YjsService(httpServer, io, sessionConfig);
 };
 
 export const getYjsService = (): YjsService => {

+ 1 - 1
apps/app/src/stores-universal/use-next-themes.tsx

@@ -1,7 +1,7 @@
 import { ColorScheme } from '@growi/core';
 import { isClient } from '@growi/core/dist/utils';
+import type { ThemeProviderProps, UseThemeProps } from 'next-themes';
 import { ThemeProvider, useTheme } from 'next-themes';
-import type { ThemeProviderProps, UseThemeProps } from 'next-themes/dist/types';
 
 import { useForcedColorScheme } from '~/states/global';
 

+ 17 - 7
apps/app/src/stores/renderer.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 import type { HtmlElementNode } from 'rehype-toc';
 import useSWR, { type SWRConfiguration, type SWRResponse } from 'swr';
 
@@ -30,12 +30,22 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
   const rendererConfig = useRendererConfigExt();
   const setTocNode = useSetTocNode();
 
-  const storeTocNodeHandler = useCallback(
-    (toc: HtmlElementNode) => {
-      setTocNode(toc);
-    },
-    [setTocNode],
-  );
+  // Store TOC node in a ref during render phase (called by rehype plugin inside ReactMarkdown),
+  // then sync to atom after commit to avoid "Cannot update a component while rendering a different component"
+  const pendingTocNodeRef = useRef<HtmlElementNode | null>(null);
+
+  const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
+    pendingTocNodeRef.current = toc;
+  }, []);
+
+  // No dependency array: runs after every render because the ref mutation
+  // is invisible to React's dependency tracking
+  useEffect(() => {
+    if (pendingTocNodeRef.current != null) {
+      setTocNode(pendingTocNodeRef.current);
+      pendingTocNodeRef.current = null;
+    }
+  });
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
   const customGenerater =

+ 1 - 1
apps/app/tsconfig.build.client.json

@@ -1,7 +1,7 @@
 {
   "$schema": "http://json.schemastore.org/tsconfig",
   "extends": "./tsconfig.json",
-  "include": [".next/types/**/*.ts"],
+  "include": [".next/types/**/*.ts", "src/@types/**/*.d.ts"],
   "compilerOptions": {
     "strict": false,
     "strictNullChecks": true,

+ 2 - 3
apps/slackbot-proxy/package.json

@@ -68,8 +68,7 @@
   },
   "// comments for devDependencies": {
     "@tsed/*": "v6.133.1 causes 'TypeError: Cannot read properties of undefined (reading 'prototype')' with `@Middleware()`",
-    "@tsed/core,exceptions": "force package to local node_modules in tsconfig.json since pnpm reads wrong hoisted tsed version (https://github.com/pnpm/pnpm/issues/7158)",
-    "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798"
+    "@tsed/core,exceptions": "force package to local node_modules in tsconfig.json since pnpm reads wrong hoisted tsed version (https://github.com/pnpm/pnpm/issues/7158)"
   },
   "devDependencies": {
     "@popperjs/core": "^2.11.8",
@@ -77,7 +76,7 @@
     "@tsed/exceptions": "=6.43.0",
     "@tsed/json-mapper": "=6.43.0",
     "@types/bunyan": "^1.8.11",
-    "bootstrap": "=5.3.2",
+    "bootstrap": "^5.3.8",
     "browser-bunyan": "^1.6.3",
     "morgan": "^1.10.0"
   }

+ 2 - 4
packages/core-styles/package.json

@@ -17,11 +17,9 @@
     "lint": "npm-run-all -p lint:*"
   },
   "dependencies": {},
-  "// comments for defDependencies": {
-    "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798"
-  },
+  "// comments for defDependencies": {},
   "devDependencies": {
-    "bootstrap": "=5.3.2"
+    "bootstrap": "^5.3.8"
   },
   "peerDependencies": {
     "@popperjs/core": "^2.11.8"

+ 6 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @growi/core
 
+## 2.2.0
+
+### Minor Changes
+
+- [#10889](https://github.com/growilabs/growi/pull/10889) [`d4be7e6`](https://github.com/growilabs/growi/commit/d4be7e68c497f168a5e39688ce0ef3760a62c98f) Thanks [@yuki-takei](https://github.com/yuki-takei)! - add YJS_WEBSOCKET_BASE_PATH
+
 ## 2.1.0
 
 ### Minor Changes

+ 3 - 6
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "2.1.0",
+  "version": "2.2.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [
@@ -69,12 +69,9 @@
     "lint": "npm-run-all -p lint:*",
     "test": "vitest run --coverage"
   },
-  "// comments for dependencies": {
-    "escape-string-regexp": "5.0.0 or above exports only ESM"
-  },
+  "// comments for dependencies": {},
   "dependencies": {
-    "bson-objectid": "^2.0.4",
-    "escape-string-regexp": "^4.0.0"
+    "bson-objectid": "^2.0.4"
   },
   "devDependencies": {
     "@types/express": "^4",

+ 5 - 0
packages/core/src/consts/ydoc-status.ts

@@ -13,3 +13,8 @@ export const YDocStatus = {
   ISOLATED: 'isolated',
 } as const;
 export type YDocStatus = (typeof YDocStatus)[keyof typeof YDocStatus];
+
+/**
+ * The base path for Yjs WebSocket connections.
+ */
+export const YJS_WEBSOCKET_BASE_PATH = '/yjs';

+ 9 - 0
packages/core/src/index.ts

@@ -1,2 +1,11 @@
 export * from './consts';
 export * from './interfaces';
+
+// Type declaration for RegExp.escape() (ES2026, Stage 4)
+// Available natively in Node.js 24+ (V8 13.x+)
+// Can be removed once TypeScript adds built-in support
+declare global {
+  interface RegExpConstructor {
+    escape(str: string): string;
+  }
+}

+ 2 - 2
packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts

@@ -18,7 +18,7 @@ describe('generateChildrenRegExp', () => {
     },
     {
       path: '/parent (with brackets)',
-      expected: '^\\/parent \\(with brackets\\)(\\/[^/]+)\\/?$',
+      expected: '^\\/parent\\x20\\(with\\x20brackets\\)(\\/[^/]+)\\/?$',
       validPaths: [
         '/parent (with brackets)/child',
         '/parent (with brackets)/test',
@@ -30,7 +30,7 @@ describe('generateChildrenRegExp', () => {
     },
     {
       path: '/parent[with square]',
-      expected: '^\\/parent\\[with square\\](\\/[^/]+)\\/?$',
+      expected: '^\\/parent\\[with\\x20square\\](\\/[^/]+)\\/?$',
       validPaths: ['/parent[with square]/child', '/parent[with square]/test'],
       invalidPaths: [
         '/parent[with square]',

+ 1 - 3
packages/core/src/utils/page-path-utils/generate-children-regexp.ts

@@ -1,5 +1,3 @@
-import escapeStringRegexp from 'escape-string-regexp';
-
 import { isTopPage } from './is-top-page';
 
 /**
@@ -12,5 +10,5 @@ export const generateChildrenRegExp = (path: string): RegExp => {
 
   // https://regex101.com/r/mrDJrx/1
   // ex. /parent/any_child OR /any_level1
-  return new RegExp(`^${escapeStringRegexp(path)}(\\/[^/]+)\\/?$`);
+  return new RegExp(`^${RegExp.escape(path)}(\\/[^/]+)\\/?$`);
 };

+ 4 - 9
packages/core/src/utils/page-path-utils/index.ts

@@ -1,5 +1,3 @@
-import escapeStringRegexp from 'escape-string-regexp';
-
 import { isValidObjectId } from '../objectid-utils';
 import { addTrailingSlash } from '../path-utils';
 import { isTopPage as _isTopPage } from './is-top-page';
@@ -149,7 +147,7 @@ export const convertToNewAffiliationPath = (
   if (newPath == null) {
     throw new Error('Please input the new page path');
   }
-  const pathRegExp = new RegExp(`^${escapeStringRegexp(oldPath)}`, 'i');
+  const pathRegExp = new RegExp(`^${RegExp.escape(oldPath)}`, 'i');
   return childPath.replace(pathRegExp, newPath);
 };
 
@@ -239,8 +237,8 @@ export const isEitherOfPathAreaOverlap = (
   const path1WithSlash = addTrailingSlash(path1);
   const path2WithSlash = addTrailingSlash(path2);
 
-  const path1Area = new RegExp(`^${escapeStringRegexp(path1WithSlash)}`, 'i');
-  const path2Area = new RegExp(`^${escapeStringRegexp(path2WithSlash)}`, 'i');
+  const path1Area = new RegExp(`^${RegExp.escape(path1WithSlash)}`, 'i');
+  const path2Area = new RegExp(`^${RegExp.escape(path2WithSlash)}`, 'i');
 
   if (path1Area.test(path2) || path2Area.test(path1)) {
     return true;
@@ -266,10 +264,7 @@ export const isPathAreaOverlap = (
 
   const pathWithSlash = addTrailingSlash(pathToTest);
 
-  const pathAreaToTest = new RegExp(
-    `^${escapeStringRegexp(pathWithSlash)}`,
-    'i',
-  );
+  const pathAreaToTest = new RegExp(`^${RegExp.escape(pathWithSlash)}`, 'i');
   if (pathAreaToTest.test(pathToBeTested)) {
     return true;
   }

+ 4 - 6
packages/editor/package.json

@@ -24,9 +24,7 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
-  "// comments for devDependencies": {
-    "string-width": "5.0.0 or above exports only ESM."
-  },
+  "// comments for devDependencies": {},
   "devDependencies": {
     "@codemirror/autocomplete": "^6.18.4",
     "@codemirror/commands": "^6.8.0",
@@ -52,7 +50,7 @@
     "@uiw/codemirror-theme-kimbie": "^4.23.8",
     "@uiw/codemirror-themes": "^4.23.8",
     "@uiw/react-codemirror": "^4.23.8",
-    "bootstrap": "=5.3.2",
+    "bootstrap": "^5.3.8",
     "cm6-theme-basic-light": "^0.2.0",
     "cm6-theme-material-dark": "^0.2.0",
     "cm6-theme-nord": "^0.2.0",
@@ -67,14 +65,14 @@
     "react-hook-form": "^7.45.4",
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.2",
-    "string-width": "=4.2.2",
+    "string-width": "^7.0.0",
     "simplebar-react": "^2.3.6",
     "socket.io": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "swr": "^2.3.2",
     "ts-deepmerge": "^6.2.0",
     "y-codemirror.next": "^0.3.5",
-    "y-socket.io": "^1.1.3",
+    "y-websocket": "^2.0.4",
     "yjs": "^13.6.19"
   }
 }

+ 59 - 73
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -1,8 +1,9 @@
 import { useEffect, useState } from 'react';
 import { keymap } from '@codemirror/view';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
-import { SocketIOProvider } from 'y-socket.io';
+import { WebsocketProvider } from 'y-websocket';
 import * as Y from 'yjs';
 
 import { userColor } from '../../consts';
@@ -30,7 +31,7 @@ export const useCollaborativeEditorMode = (
       useSecondary: reviewMode,
     }) ?? {};
 
-  const [provider, setProvider] = useState<SocketIOProvider>();
+  const [provider, setProvider] = useState<WebsocketProvider>();
 
   // reset editors
   useEffect(() => {
@@ -40,85 +41,70 @@ export const useCollaborativeEditorMode = (
 
   // Setup provider
   useEffect(() => {
-    let _provider: SocketIOProvider | undefined;
-    let providerSyncHandler: (isSync: boolean) => void;
-    let updateAwarenessHandler: (update: {
+    if (!isEnabled || pageId == null || primaryDoc == null) {
+      setProvider(undefined);
+      return;
+    }
+
+    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const serverUrl = `${wsProtocol}//${window.location.host}${YJS_WEBSOCKET_BASE_PATH}`;
+
+    const _provider = new WebsocketProvider(serverUrl, pageId, primaryDoc, {
+      connect: true,
+      resyncInterval: 3000,
+    });
+
+    const userLocalState: EditingClient = {
+      clientId: primaryDoc.clientID,
+      name: user?.name ?? `Guest User ${Math.floor(Math.random() * 100)}`,
+      userId: user?._id,
+      username: user?.username,
+      imageUrlCached: user?.imageUrlCached,
+      color: userColor.color,
+      colorLight: userColor.light,
+    };
+
+    const { awareness } = _provider;
+    awareness.setLocalStateField('editors', userLocalState);
+
+    const emitEditorList = () => {
+      if (onEditorsUpdated == null) return;
+      const clientList: EditingClient[] = Array.from(
+        awareness.getStates().values(),
+        (value) => value.editors,
+      );
+      if (Array.isArray(clientList)) {
+        onEditorsUpdated(clientList);
+      }
+    };
+
+    const providerSyncHandler = (isSync: boolean) => {
+      if (isSync) emitEditorList();
+    };
+
+    _provider.on('sync', providerSyncHandler);
+
+    const updateAwarenessHandler = (update: {
       added: number[];
       updated: number[];
       removed: number[];
-    }) => void;
-
-    setProvider(() => {
-      if (!isEnabled || pageId == null || primaryDoc == null) {
-        return undefined;
+    }) => {
+      for (const clientId of update.removed) {
+        awareness.getStates().delete(clientId);
       }
+      emitEditorList();
+    };
 
-      _provider = new SocketIOProvider('/', pageId, primaryDoc, {
-        autoConnect: true,
-        resyncInterval: 3000,
-      });
+    awareness.on('update', updateAwarenessHandler);
 
-      const userLocalState: EditingClient = {
-        clientId: primaryDoc.clientID,
-        name: user?.name ?? `Guest User ${Math.floor(Math.random() * 100)}`,
-        userId: user?._id,
-        username: user?.username,
-        imageUrlCached: user?.imageUrlCached,
-        color: userColor.color,
-        colorLight: userColor.light,
-      };
-
-      const { awareness } = _provider;
-      awareness.setLocalStateField('editors', userLocalState);
-
-      providerSyncHandler = (isSync: boolean) => {
-        if (isSync && onEditorsUpdated != null) {
-          const clientList: EditingClient[] = Array.from(
-            awareness.getStates().values(),
-            (value) => value.editors,
-          );
-          if (Array.isArray(clientList)) {
-            onEditorsUpdated(clientList);
-          }
-        }
-      };
-
-      _provider.on('sync', providerSyncHandler);
-
-      // update args type see: SocketIOProvider.Awareness.awarenessUpdate
-      updateAwarenessHandler = (update: {
-        added: number[];
-        updated: number[];
-        removed: number[];
-      }) => {
-        // remove the states of disconnected clients
-        update.removed.forEach((clientId) => {
-          awareness.states.delete(clientId);
-        });
-
-        // update editor list
-        if (onEditorsUpdated != null) {
-          const clientList: EditingClient[] = Array.from(
-            awareness.states.values(),
-            (value) => value.editors,
-          );
-          if (Array.isArray(clientList)) {
-            onEditorsUpdated(clientList);
-          }
-        }
-      };
-
-      awareness.on('update', updateAwarenessHandler);
-
-      return _provider;
-    });
+    setProvider(_provider);
 
     return () => {
-      _provider?.awareness.setLocalState(null);
-      _provider?.awareness.off('update', updateAwarenessHandler);
-      _provider?.off('sync', providerSyncHandler);
-      _provider?.disconnect();
-      _provider?.destroy();
+      _provider.awareness.setLocalState(null);
+      _provider.awareness.off('update', updateAwarenessHandler);
+      _provider.off('sync', providerSyncHandler);
+      _provider.disconnect();
+      _provider.destroy();
     };
   }, [isEnabled, primaryDoc, onEditorsUpdated, pageId, user]);
 

+ 21 - 17
packages/editor/vite.config.ts

@@ -1,12 +1,13 @@
 import path from 'node:path';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import react from '@vitejs/plugin-react';
 import glob from 'glob';
 import { nodeExternals } from 'rollup-plugin-node-externals';
-import { Server } from 'socket.io';
 import type { Plugin } from 'vite';
 import { defineConfig } from 'vite';
 import dts from 'vite-plugin-dts';
-import { YSocketIO } from 'y-socket.io/dist/server';
+
+const YJS_PATH_PREFIX = `${YJS_WEBSOCKET_BASE_PATH}/`;
 
 const excludeFiles = [
   '**/components/playground/*',
@@ -14,27 +15,30 @@ const excludeFiles = [
   '**/vite-env.d.ts',
 ];
 
-const devSocketIOPlugin = (): Plugin => ({
-  name: 'dev-socket-io',
+const devWebSocketPlugin = (): Plugin => ({
+  name: 'dev-y-websocket',
   apply: 'serve',
   configureServer(server) {
     if (!server.httpServer) return;
 
-    // setup socket.io
-    const io = new Server(server.httpServer);
-    io.on('connection', (socket) => {
-      // biome-ignore lint/suspicious/noConsole: Allow to use
-      console.log('Client connected');
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const { setupWSConnection } = require('y-websocket/bin/utils');
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const { WebSocketServer } = require('ws');
+
+    const wss = new WebSocketServer({ noServer: true });
+
+    server.httpServer.on('upgrade', (request, socket, head) => {
+      const url = request.url ?? '';
+      if (!url.startsWith(YJS_PATH_PREFIX)) return;
 
-      socket.on('disconnect', () => {
-        // biome-ignore lint/suspicious/noConsole: Allow to use
-        console.log('Client disconnected');
+      const pageId = url.slice(YJS_PATH_PREFIX.length).split('?')[0];
+
+      wss.handleUpgrade(request, socket, head, (ws) => {
+        wss.emit('connection', ws, request);
+        setupWSConnection(ws, request, { docName: pageId });
       });
     });
-
-    // setup y-socket.io
-    const ysocketio = new YSocketIO(io);
-    ysocketio.initialize();
   },
 });
 
@@ -42,7 +46,7 @@ const devSocketIOPlugin = (): Plugin => ({
 export default defineConfig({
   plugins: [
     react(),
-    devSocketIOPlugin(),
+    devWebSocketPlugin(),
     dts({
       entryRoot: 'src',
       exclude: [...excludeFiles],

+ 7 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,12 @@
 # @growi/pluginkit
 
+## 1.2.2
+
+### Patch Changes
+
+- Updated dependencies [[`d4be7e6`](https://github.com/growilabs/growi/commit/d4be7e68c497f168a5e39688ce0ef3760a62c98f)]:
+  - @growi/core@2.2.0
+
 ## 1.2.1
 
 ### Patch Changes

+ 1 - 1
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pluginkit",
-  "version": "1.2.1",
+  "version": "1.2.2",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "main": "dist/index.cjs",

+ 1 - 1
packages/preset-themes/package.json

@@ -31,7 +31,7 @@
   "devDependencies": {
     "@growi/core": "workspace:^",
     "@growi/core-styles": "workspace:^",
-    "bootstrap": "=5.3.2",
+    "bootstrap": "^5.3.8",
     "sass": "^1.55.0"
   },
   "peerDependencies": {

+ 1 - 4
packages/remark-lsx/package.json

@@ -29,14 +29,11 @@
     "lint": "run-p lint:*",
     "test": "vitest run --coverage"
   },
-  "// comments for dependencies": {
-    "escape-string-regexp": "5.0.0 or above exports only ESM"
-  },
+  "// comments for dependencies": {},
   "dependencies": {
     "@growi/core": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/ui": "workspace:^",
-    "escape-string-regexp": "^4.0.0",
     "express": "^4.20.0",
     "express-validator": "^6.14.0",
     "http-errors": "^2.0.0",

+ 12 - 4
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -186,7 +186,9 @@ describe('listPages', () => {
       // setup
       const pagePath = '/parent';
       const optionsFilter = '^child';
-      const expectedRegex = /^\/parent\/child/;
+      const expectedRegex = new RegExp(
+        `^${RegExp.escape('/parent/')}${RegExp.escape('child')}`,
+      );
 
       // when
       addFilterCondition(queryMock, pagePath, optionsFilter);
@@ -199,7 +201,9 @@ describe('listPages', () => {
       // setup
       const pagePath = '/parent';
       const optionsFilter = 'child';
-      const expectedRegex = /^\/parent\/.*child/;
+      const expectedRegex = new RegExp(
+        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+      );
 
       // when
       addFilterCondition(queryMock, pagePath, optionsFilter);
@@ -225,7 +229,9 @@ describe('listPages', () => {
       // setup
       const pagePath = '/parent';
       const optionsFilter = 'child';
-      const expectedRegex = /^\/parent\/.*child/;
+      const expectedRegex = new RegExp(
+        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+      );
 
       // when
       addFilterCondition(queryMock, pagePath, optionsFilter, true);
@@ -313,7 +319,9 @@ describe('when excludedPaths is handled', () => {
     await handler(reqMock, resMock);
 
     // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$)
-    const expectedRegex = /^\/(user|tmp)(\/|$)/;
+    const expectedRegex = new RegExp(
+      `^\\/(${RegExp.escape('user')}|${RegExp.escape('tmp')})(\\/|$)`,
+    );
     expect(queryMock.and).toHaveBeenCalledWith([
       {
         path: { $not: expectedRegex },

+ 4 - 5
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,7 +1,6 @@
 import type { IUser } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { pathUtils } from '@growi/core/dist/utils';
-import escapeStringRegexp from 'escape-string-regexp';
 import type { Request, Response } from 'express';
 import createError, { isHttpError } from 'http-errors';
 
@@ -31,16 +30,16 @@ export function addFilterCondition(
     );
   }
 
-  const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
+  const pagePathForRegexp = RegExp.escape(addTrailingSlash(pagePath));
 
   let filterPath: RegExp;
   try {
     if (optionsFilter.charAt(0) === '^') {
       // move '^' to the first of path
-      const escapedFilter = escapeStringRegexp(optionsFilter.slice(1));
+      const escapedFilter = RegExp.escape(optionsFilter.slice(1));
       filterPath = new RegExp(`^${pagePathForRegexp}${escapedFilter}`);
     } else {
-      const escapedFilter = escapeStringRegexp(optionsFilter);
+      const escapedFilter = RegExp.escape(optionsFilter);
       filterPath = new RegExp(`^${pagePathForRegexp}.*${escapedFilter}`);
     }
   } catch (err) {
@@ -100,7 +99,7 @@ export const listPages = ({
       if (excludedPaths.length > 0) {
         const escapedPaths = excludedPaths.map((p) => {
           const cleanPath = p.startsWith('/') ? p.substring(1) : p;
-          return escapeStringRegexp(cleanPath);
+          return RegExp.escape(cleanPath);
         });
 
         const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`);

Разница между файлами не показана из-за своего большого размера
+ 295 - 291
pnpm-lock.yaml


Некоторые файлы не были показаны из-за большого количества измененных файлов