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

add optimise-deps-for-prod spec

Yuki Takei 3 недель назад
Родитель
Сommit
63791c5ca6

+ 367 - 0
.kiro/specs/optimise-deps-for-prod/design.md

@@ -0,0 +1,367 @@
+# Design Document: optimise-deps-for-prod
+
+## Overview
+
+This feature corrects the `devDependencies` / `dependencies` classification in `apps/app/package.json` for packages that Turbopack externalises at SSR runtime. When webpack was the bundler, all imports were inlined into self-contained chunks, making `devDependencies` sufficient. Turbopack instead loads certain packages at runtime via `.next/node_modules/` symlinks; `pnpm deploy --prod` excludes `devDependencies`, breaking the production server.
+
+**Purpose**: Restore a working production build and then minimise the `dependencies` set to only what is genuinely required at runtime.
+
+**Users**: Release engineers and developers maintaining the production deployment pipeline.
+
+**Impact**: Modifies `apps/app/package.json` and up to four source files; no changes to user-facing features or API contracts.
+
+### Goals
+
+- `pnpm deploy --prod` produces a complete, self-contained production artifact (Req 1)
+- Minimise `dependencies` by reverting packages where technically safe (Req 2, 3, 4)
+- Document the Turbopack externalisation rule to prevent future misclassification (Req 5)
+
+### Non-Goals
+
+- Changes to Turbopack configuration or build pipeline beyond `assemble-prod.sh`
+- Refactoring of feature logic or component APIs unrelated to SSR behaviour
+- Migration from Pages Router to App Router
+
+---
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Flows |
+|-------------|---------|------------|-------|
+| 1.1–1.5 | Move all 23 packages to `dependencies`; verify no missing-module errors | `package.json` | Phase 1 |
+| 2.1–2.4 | Investigate and optionally remove `@emoji-mart/data` server-side import | `emoji.ts` | Phase 2 |
+| 3.1–3.5 | Apply `dynamic({ ssr: false })` to eligible Group 1 components | Targeted component files | Phase 3 |
+| 4.1–4.5 | Resolve `react-toastify`, `socket.io-client`, `bootstrap`, phantom packages | `admin/states/socket-io.ts`, `_app.page.tsx` | Phase 4 |
+| 5.1–5.5 | Validate final state; add Turbopack externalisation rule documentation | `package.json`, steering doc | Phase 5 |
+
+---
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The production assembly pipeline is:
+
+```
+turbo run build
+  └─ Turbopack build → .next/ (with .next/node_modules/ symlinks → ../../../../node_modules/.pnpm/)
+
+assemble-prod.sh
+  ├─ pnpm deploy out --prod --legacy   → out/node_modules/ (pnpm-native: .pnpm/ + symlinks)
+  ├─ rm + mv out/node_modules → apps/app/node_modules/
+  ├─ rm -rf .next/cache
+  ├─ next.config.ts removal
+  └─ symlink rewrite: ../../../../ → ../../ in .next/node_modules/
+
+cp -a to /tmp/release/           → preserves pnpm symlinks intact
+COPY --from=builder /tmp/release/ → release image
+```
+
+The symlink rewrite step is essential: `.next/node_modules/` symlinks point to the workspace-root `.pnpm/` (4 levels up), which does not exist in the release image. After rewriting to 2 levels up, they resolve to `apps/app/node_modules/.pnpm/` (included in the deploy output), preserving pnpm's sibling-resolution for transitive dependencies.
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph BuildEnv
+        TurboBuild[turbo run build]
+        NextModules[.next/node_modules symlinks]
+        AssembleScript[assemble-prod.sh]
+        DeployOut[pnpm deploy out]
+        AppNodeModules[apps/app/node_modules .pnpm + symlinks]
+        RewriteStep[symlink rewrite step]
+    end
+
+    subgraph ReleaseImage
+        ReleaseDist[release artifact]
+        ProdServer[pnpm run server]
+    end
+
+    TurboBuild --> NextModules
+    AssembleScript --> DeployOut
+    DeployOut --> AppNodeModules
+    AssembleScript --> RewriteStep
+    RewriteStep --> NextModules
+    AppNodeModules --> ReleaseDist
+    NextModules --> ReleaseDist
+    ReleaseDist --> ProdServer
+```
+
+**Key decisions**:
+- Symlink rewrite (not `cp -rL`) preserves pnpm's sibling resolution for transitive deps (see `research.md` — Decision: Symlink Rewrite over cp -rL).
+- `pnpm deploy --prod` (not `--dev`) is the correct scope; only runtime packages belong in the artifact.
+
+### Technology Stack
+
+| Layer | Choice | Role | Notes |
+|-------|--------|------|-------|
+| Package manifest | `apps/app/package.json` | Declares runtime vs build-time deps | 23 entries move from `devDependencies` to `dependencies` |
+| Build assembly | `apps/app/bin/assemble-prod.sh` | Produces self-contained release artifact | Already contains symlink rewrite; no changes needed in Phase 1 |
+| Bundler | Turbopack (Next.js 16) | Externalises packages to `.next/node_modules/` | Externalisation heuristic: static module-level imports in SSR code paths |
+| Package manager | pnpm v10 with `--legacy` deploy | Produces pnpm-native `node_modules` with `.pnpm/` virtual store | `inject-workspace-packages` not required with `--legacy` |
+
+---
+
+## System Flows
+
+### Phased Execution Flow
+
+```mermaid
+graph TB
+    P1[Phase 1: Move all 23 to dependencies]
+    P1Check{Production server OK?}
+    P2[Phase 2: Investigate @emoji-mart/data server import]
+    P2Result{Removal viable without breaking emoji?}
+    P2Revert[Move @emoji-mart/data back to devDependencies]
+    P2Keep[Document as justified production dep]
+    P3[Phase 3: Apply ssr:false to eligible Group 1 components]
+    P4[Phase 4: Resolve ambiguous packages]
+    P5[Phase 5: Final validation and documentation]
+
+    P1 --> P1Check
+    P1Check -->|No| P1
+    P1Check -->|Yes| P2
+    P2 --> P2Result
+    P2Result -->|Yes| P2Revert
+    P2Result -->|No| P2Keep
+    P2Revert --> P3
+    P2Keep --> P3
+    P3 --> P4
+    P4 --> P5
+```
+
+Each phase gate requires: production server starts without errors + login page returns HTTP 200.
+
+---
+
+## Components and Interfaces
+
+### Summary Table
+
+| Component | Domain | Intent | Req Coverage | Contracts |
+|-----------|--------|--------|--------------|-----------|
+| `package.json` | Build Config | Dependency manifest | 1.1, 2.3, 2.4, 3.4, 3.5, 4.1–4.4, 5.3 | State |
+| `emoji.ts` (remark plugin) | Server Renderer | Emoji shortcode → native emoji lookup | 2.1, 2.2, 2.3 | Service |
+| Admin socket atom | Client State | Socket.IO connection for admin panel | 4.2 | State |
+| Group 1 component wrappers | UI | `dynamic({ ssr: false })` wrapping for eligible components | 3.1–3.5 | — |
+
+---
+
+### Build Config
+
+#### `apps/app/package.json`
+
+| Field | Detail |
+|-------|--------|
+| Intent | Central manifest governing which packages are included in `pnpm deploy --prod` output |
+| Requirements | 1.1, 2.3, 2.4, 3.4, 3.5, 4.1–4.4, 5.3 |
+
+**Responsibilities & Constraints**
+- Determines the complete set of packages in the production artifact.
+- Any package appearing in `.next/node_modules/` after a production build must be in `dependencies`, not `devDependencies`.
+- Changes propagate to all consumers of the monorepo lock file; `pnpm install --frozen-lockfile` must remain valid.
+
+**Phase 1 changes — move all 23 to `dependencies`**:
+
+| Package | Current | Target | Rationale |
+|---------|---------|--------|-----------|
+| `@codemirror/state` | devDep | dep | Used in editor components (SSR'd) |
+| `@emoji-mart/data` | devDep | dep | Static import in remark plugin (server-side) |
+| `@handsontable/react` | devDep | dep | Used in HandsontableModal (SSR'd unless wrapped) |
+| `@headless-tree/core` | devDep | dep | Used in PageTree hooks (SSR'd) |
+| `@headless-tree/react` | devDep | dep | Used in ItemsTree (SSR'd) |
+| `@tanstack/react-virtual` | devDep | dep | Used in ItemsTree (layout-critical, SSR'd) |
+| `bootstrap` | devDep | dep | Dynamic JS import in `_app.page.tsx` (Phase 4 to verify) |
+| `diff2html` | devDep | dep | Used in RevisionDiff (SSR'd) |
+| `downshift` | devDep | dep | Used in SearchModal (SSR'd) |
+| `fastest-levenshtein` | devDep | dep | Used in openai client service (SSR'd) |
+| `fslightbox-react` | devDep | dep | Used in LightBox (SSR'd) |
+| `i18next-http-backend` | devDep | dep | Present in `.next/node_modules/`; source unknown (Phase 4 to verify) |
+| `i18next-localstorage-backend` | devDep | dep | Present in `.next/node_modules/`; source unknown (Phase 4 to verify) |
+| `pretty-bytes` | devDep | dep | Used in RichAttachment (SSR'd) |
+| `react-copy-to-clipboard` | devDep | dep | Used in multiple inline components (SSR'd) |
+| `react-dnd` | devDep | dep | Used in PageTree drag-drop (SSR'd) |
+| `react-dnd-html5-backend` | devDep | dep | Used in PageTree drag-drop (SSR'd) |
+| `react-dropzone` | devDep | dep | Present in `.next/node_modules/`; source unknown (Phase 4 to verify) |
+| `react-hook-form` | devDep | dep | Used in forms across app (SSR'd) |
+| `react-input-autosize` | devDep | dep | Used in form inputs (SSR'd) |
+| `react-toastify` | devDep | dep | Static import in `toastr.ts` (SSR'd) |
+| `simplebar-react` | devDep | dep | Used in Sidebar, AiAssistant (layout-critical, SSR'd) |
+| `socket.io-client` | devDep | dep | Static import in admin socket atom (Phase 4 refactor) |
+
+**Phase 2–4 revert candidates** (move back to `devDependencies` if conditions met):
+
+| Package | Condition for revert | Phase |
+|---------|----------------------|-------|
+| `@emoji-mart/data` | Server-side import removed or replaced with static file | 2 |
+| `fslightbox-react` | Wrapped with `dynamic({ ssr: false })`; no longer in `.next/node_modules/` | 3 |
+| `diff2html` | Wrapped with `dynamic({ ssr: false })`; no longer in `.next/node_modules/` | 3 |
+| `react-dnd` | DnD-specific components wrapped with `dynamic({ ssr: false })` | 3 |
+| `react-dnd-html5-backend` | Same as `react-dnd` | 3 |
+| `@handsontable/react` | Confirmed `dynamic({ ssr: false })` in HandsontableModal | 3 |
+| `socket.io-client` | Admin socket refactored to dynamic import | 4 |
+| `bootstrap` | Confirmed `import()` is browser-only (inside `useEffect`) | 4 |
+| `i18next-http-backend` | Confirmed absent from `.next/node_modules/` post-Phase-1 | 4 |
+| `i18next-localstorage-backend` | Confirmed absent from `.next/node_modules/` post-Phase-1 | 4 |
+| `react-dropzone` | Confirmed absent from `.next/node_modules/` post-Phase-1 | 4 |
+
+**Contracts**: State [x]
+
+**Implementation Notes**
+- Integration: Edit `apps/app/package.json` directly; run `pnpm install --frozen-lockfile` to verify lock file integrity after changes.
+- Validation: After each phase, run `assemble-prod.sh` locally and start `pnpm run server`; confirm no `ERR_MODULE_NOT_FOUND` in logs and HTTP 200 on `/login`.
+- Risks: Moving packages from `devDependencies` to `dependencies` may increase Docker image size; acceptable trade-off for Phase 1.
+
+---
+
+### Server Renderer
+
+#### `apps/app/src/services/renderer/remark-plugins/emoji.ts`
+
+| Field | Detail |
+|-------|--------|
+| Intent | Resolve `:emoji-name:` shortcodes to native emoji characters during Markdown SSR |
+| Requirements | 2.1, 2.2, 2.3 |
+
+**Responsibilities & Constraints**
+- Processes Markdown AST server-side via `findAndReplace`.
+- Uses `@emoji-mart/data/sets/15/native.json` only to look up `emojiData.emojis[name]?.skins[0].native`.
+- Must produce identical output before and after any refactor (Req 2.2).
+
+**Dependencies**
+- External: `@emoji-mart/data` — static emoji data (P1, Phase 2 investigation target)
+
+**Contracts**: Service [x]
+
+**Phase 2 investigation**: Determine whether `@emoji-mart/data` can be replaced with a repo-bundled static lookup file. The required data structure is:
+
+```typescript
+interface EmojiNativeLookup {
+  emojis: Record<string, { skins: [{ native: string }] }>;
+}
+```
+
+If a static extraction script (run at package update time) can produce this file, `@emoji-mart/data` can revert to `devDependencies`. If not, document as justified production dependency per Req 2.4.
+
+**Implementation Notes**
+- Integration: Any replacement file must be a static JSON import; no runtime fetch.
+- Validation: Render a Markdown document containing known emoji shortcodes (`:+1:`, `:tada:`, etc.) and verify the native characters appear in the output.
+- Risks: Static extraction requires a maintenance step when `@emoji-mart/data` is upgraded.
+
+---
+
+### Client State
+
+#### `apps/app/src/features/admin/states/socket-io.ts`
+
+| Field | Detail |
+|-------|--------|
+| Intent | Jotai atom managing Socket.IO connection for the admin panel |
+| Requirements | 4.2 |
+
+**Responsibilities & Constraints**
+- Provides an `io` Socket.IO client instance to admin panel components.
+- The static `import io from 'socket.io-client'` at module level causes Turbopack to externalise `socket.io-client` for SSR.
+
+**Dependencies**
+- External: `socket.io-client` — WebSocket client (P1 if static; P2 after dynamic import refactor)
+
+**Contracts**: State [x]
+
+**Phase 4 refactor target**: Replace static import with dynamic import to match the pattern in `states/socket-io/global-socket.ts`:
+
+```typescript
+// Before (causes SSR externalisation)
+import io from 'socket.io-client';
+
+// After (browser-only, matches global-socket.ts pattern)
+const io = (await import('socket.io-client')).default;
+```
+
+**Implementation Notes**
+- Integration: The atom must be an async atom or use `atomWithLazy` to defer the import.
+- Validation: Verify admin socket connects in browser; verify `socket.io-client` no longer appears in `.next/node_modules/` after a production build.
+- Risks: Admin socket consumers must handle the async initialisation; if synchronous access is required at page load, the refactor may not be feasible.
+
+---
+
+### UI — `dynamic({ ssr: false })` Wrapper Points
+
+These are not new components; they are targeted wrapping of existing imports using Next.js `dynamic`.
+
+**Phase 3 evaluation criteria**:
+1. Component renders interactive content only (no meaningful text for SEO)
+2. Initial HTML without the component does not cause visible layout shift
+3. No hydration mismatch after applying `ssr: false`
+
+| Component | Package | `ssr: false` Feasibility | Notes |
+|-----------|---------|-------------------------|-------|
+| `LightBox.tsx` | `fslightbox-react` | High — renders only after user interaction | No SSR content |
+| `RevisionDiff.tsx` | `diff2html` | High — interactive diff viewer, no SEO content | Loaded on user action |
+| `PageTree.tsx` drag-drop | `react-dnd`, `react-dnd-html5-backend` | Medium — DnD provider wraps tree; tree content is SSR'd | Wrap DnD provider only, not content |
+| `HandsontableModal.tsx` | `@handsontable/react` | High — modal, not in initial HTML | Verify existing dynamic import pattern |
+| `SearchModal.tsx` | `downshift` | Low — search input in sidebar, part of hydration | Risk of layout shift |
+| openai fuzzy matching | `fastest-levenshtein` | Medium — algorithm utility; depends on call site | May be callable lazily |
+
+**Contracts**: None (pure wrapping changes, no new interfaces)
+
+**Implementation Notes**
+- Apply `dynamic` wrapping to the specific consuming component file, not to the package entry point.
+- Validation per component: (a) build with package removed from `dependencies`, (b) confirm it disappears from `.next/node_modules/`, (c) confirm no hydration warnings in browser console.
+- Risks: Wrapping components that render visible content may cause flash of missing content (FOMC); test on slow connections.
+
+---
+
+## Testing Strategy
+
+### Phase 1 — Smoke Test (Req 1.3, 1.4, 1.5)
+
+- Start production server after `assemble-prod.sh`: confirm no `ERR_MODULE_NOT_FOUND` in stdout.
+- HTTP GET `/login`: assert 200 response and absence of SSR error log lines.
+- Run `launch-prod` CI job: assert job passes against MongoDB 6.0 and 8.0.
+
+### Phase 2 — Emoji Rendering (Req 2.2)
+
+- Unit test: render Markdown string containing `:+1:`, `:tada:`, `:rocket:` through the remark plugin; assert native emoji characters in output.
+- If static file replacement applied: run same test against replacement; assert identical output.
+
+### Phase 3 — Hydration Integrity (Req 3.3)
+
+- Per-component browser test: load page containing the wrapped component; assert no React hydration warnings in browser console.
+- Visual regression: screenshot comparison of affected pages before and after `ssr: false` wrapping.
+
+### Phase 4 — Admin Socket and Bootstrap (Req 4.2, 4.3)
+
+- Admin socket: open admin panel in browser; assert Socket.IO connection established (WebSocket upgrade in browser DevTools Network tab).
+- Bootstrap: assert Bootstrap dropdown/modal JavaScript functions correctly in browser after confirming `import()` placement.
+
+### Phase 5 — Final Coverage Check (Req 5.1, 5.3)
+
+- Automated check (add to `assemble-prod.sh` or CI): after deploy, assert that every symlink in `apps/app/.next/node_modules/` resolves to an existing file in `apps/app/node_modules/.pnpm/`.
+- Assert no package listed in `devDependencies` appears in `apps/app/.next/node_modules/` after a production build.
+
+---
+
+## Migration Strategy
+
+The five phases are executed sequentially. Each phase is independently deployable and verifiable.
+
+```mermaid
+graph LR
+    P1[Phase 1\nBaseline fix\nall 23 to deps]
+    P2[Phase 2\n@emoji-mart/data\nserver import]
+    P3[Phase 3\nssr:false\nGroup 1 candidates]
+    P4[Phase 4\nAmbiguous\npackages]
+    P5[Phase 5\nValidation\ndocumentation]
+
+    P1 --> P2
+    P2 --> P3
+    P3 --> P4
+    P4 --> P5
+```
+
+**Rollback**: Each phase modifies only `package.json` and/or one source file. Rolling back is a targeted revert of those changes; the production build pipeline (`assemble-prod.sh`, Dockerfile) is unchanged throughout.
+
+**Phase 1 rollback trigger**: Production server fails to start or CI `launch-prod` fails → revert `package.json` changes.
+
+**Phase 3/4 rollback trigger**: Hydration error or functional regression detected → revert the specific `dynamic()` wrapping or import refactor; package remains in `dependencies`.

+ 78 - 0
.kiro/specs/optimise-deps-for-prod/requirements.md

@@ -0,0 +1,78 @@
+# Requirements Document
+
+## Introduction
+
+GROWI migrated its production bundler from webpack to Turbopack. Unlike webpack, which inlines all imported modules into self-contained bundle chunks, Turbopack externalises certain packages at SSR runtime via `.next/node_modules/` symlinks. This means packages that were historically classified as `devDependencies` (on the assumption that they were only needed at build time) are now required at production runtime.
+
+The current `pnpm deploy --prod` step excludes `devDependencies`, causing missing modules when the production server starts. This specification defines a phased approach: first restore a working production build by moving all affected packages to `dependencies`, then systematically minimise the `dependencies` set by eliminating unnecessary runtime exposure where technically feasible.
+
+---
+
+## Requirements
+
+### Requirement 1: Production Build Baseline Restoration
+
+**Objective:** As a release engineer, I want `pnpm deploy --prod` to produce a complete, self-contained production artifact, so that the production server starts without missing-module errors.
+
+#### Acceptance Criteria
+
+1. The build system shall move all 23 packages currently appearing in `.next/node_modules/` but classified as `devDependencies` into `dependencies` in `apps/app/package.json`.
+2. When `pnpm deploy out --prod --legacy --filter @growi/app` is executed, the output `node_modules` shall contain every package referenced by `.next/node_modules/` symlinks.
+3. When the production server is started with `pnpm run server`, the build system shall not throw `ERR_MODULE_NOT_FOUND` or `Failed to load external module` errors on any page request.
+4. When a browser accesses the `/login` page, the GROWI server shall respond with HTTP 200 and render the login page without SSR errors in the server log.
+5. The GROWI server shall pass existing CI smoke tests (`launch-prod` job) after this change.
+
+---
+
+### Requirement 2: Elimination of Server-Side Imports for Group 2 Packages
+
+**Objective:** As a developer, I want to remove direct server-side imports of packages that do not need to run on the server, so that those packages can revert to `devDependencies` and be excluded from the production artifact.
+
+#### Acceptance Criteria
+
+1. When `@emoji-mart/data` is investigated, the build system shall determine whether its import in `services/renderer/remark-plugins/emoji.ts` can be replaced with a server-safe alternative (e.g., a bundled subset of emoji data, or a lazy `require()` that avoids Turbopack externalisation).
+2. If a viable server-side import removal is identified for a Group 2 package, the GROWI server shall continue to render emoji correctly in Markdown output after the refactor.
+3. If a Group 2 package's server-side import is successfully removed and it no longer appears in `.next/node_modules/` after a production build, the build system shall move that package back to `devDependencies`.
+4. If removal of a server-side import is not viable without breaking functionality, the package shall remain in `dependencies` and be documented as a justified production dependency.
+
+---
+
+### Requirement 3: SSR Opt-out for Group 1 Client-Only Components
+
+**Objective:** As a developer, I want to wrap client-only UI components with `dynamic(() => import(...), { ssr: false })` where appropriate, so that their dependencies are excluded from Turbopack's SSR externalisation and can revert to `devDependencies`.
+
+#### Acceptance Criteria
+
+1. When a Group 1 package is evaluated, the build system shall determine whether its consuming component(s) can be safely rendered client-side only (no meaningful content lost from initial HTML, no SEO impact, no hydration mismatch risk).
+2. Where a component is safe for `ssr: false`, the GROWI app shall wrap the component import with `dynamic(() => import('...'), { ssr: false })` and the package shall no longer appear in `.next/node_modules/` after a production build.
+3. When `ssr: false` is applied to a component, the GROWI app shall not exhibit hydration errors or visible layout shift in the browser.
+4. If a Group 1 package's component is successfully converted to `ssr: false` and no longer appears in `.next/node_modules/`, the build system shall move that package back to `devDependencies`.
+5. If a component cannot be wrapped with `ssr: false` without breaking functionality or user experience, the package shall remain in `dependencies` and be documented as a justified production dependency.
+
+---
+
+### Requirement 4: Classification of Unresolved Packages
+
+**Objective:** As a developer, I want to determine the correct final classification for packages with unclear or mixed usage patterns, so that every entry in `dependencies` and `devDependencies` has a documented and verified rationale.
+
+#### Acceptance Criteria
+
+1. When `react-toastify` is investigated, the build system shall determine whether the direct import in `client/util/toastr.ts` causes Turbopack to externalise it independently of the `ssr: false`-guarded `ToastContainer`, and classify accordingly.
+2. When `socket.io-client` is investigated, the build system shall determine whether the direct import in `features/admin/states/socket-io.ts` requires the package at SSR runtime, and either refactor it to a dynamic import or document it as a justified production dependency.
+3. When `bootstrap` is investigated, the build system shall determine whether a JavaScript import (beyond SCSS) causes it to appear in `.next/node_modules/`, and classify accordingly.
+4. When `i18next-http-backend`, `i18next-localstorage-backend`, and `react-dropzone` are investigated and found to have no direct imports in `src/`, the build system shall determine whether they appear in `.next/node_modules/` via transitive dependencies and remove them from `devDependencies` entirely if they are unused.
+5. The GROWI server shall pass existing CI smoke tests after all reclassifications in this requirement are applied.
+
+---
+
+### Requirement 5: Final State Validation and Documentation
+
+**Objective:** As a release engineer, I want a verified and documented final state of `dependencies` vs `devDependencies`, so that future package additions follow the correct classification rules.
+
+#### Acceptance Criteria
+
+1. The GROWI build system shall produce a production artifact where every package in `.next/node_modules/` is resolvable from `apps/app/node_modules/` (i.e., no broken symlinks in the release image).
+2. The GROWI server shall start and serve the login page without errors after a full `pnpm deploy --prod` cycle.
+3. The `apps/app/package.json` shall contain no packages in `devDependencies` that appear in `.next/node_modules/` after a production build.
+4. The build system shall pass the full `launch-prod` CI job including MongoDB connectivity checks.
+5. The GROWI codebase shall include a comment or documentation entry explaining the Turbopack externalisation rule: any package imported in SSR-rendered code (including Pages Router components and server-side utilities) must be in `dependencies`, not `devDependencies`.

+ 141 - 0
.kiro/specs/optimise-deps-for-prod/research.md

@@ -0,0 +1,141 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Capture discovery findings and rationale for the `optimise-deps-for-prod` specification.
+
+**Usage**: Background investigation notes referenced by `design.md`.
+
+---
+
+## Summary
+
+- **Feature**: `optimise-deps-for-prod`
+- **Discovery Scope**: Extension — modifying `apps/app/package.json` and targeted source files
+- **Key Findings**:
+  - All 23 packages confirmed present in `apps/app/.next/node_modules/` after a Turbopack production build, verifying they are Turbopack SSR externals.
+  - `pnpm deploy --prod --legacy` produces a pnpm-native isolated structure (`node_modules/.pnpm/` + symlinks), NOT a flat/hoisted layout; transitive deps are only in `.pnpm/`, not hoisted to top-level.
+  - Three packages (`i18next-http-backend`, `i18next-localstorage-backend`, `react-dropzone`) have no direct imports in `apps/app/src/` and may be transitive phantom entries or unused.
+
+---
+
+## Research Log
+
+### Turbopack SSR Externalisation Mechanism
+
+- **Context**: Why does Turbopack create `.next/node_modules/` at all, and what determines which packages end up there?
+- **Findings**:
+  - Turbopack (Next.js 16) externalises packages for SSR when they are imported in code that runs server-side, instead of inlining them into the SSR bundle as webpack does.
+  - In Next.js Pages Router, every page component is SSR'd for initial HTML regardless of `"use client"` directives. Only `dynamic(() => import('...'), { ssr: false })` prevents server-side execution.
+  - The resulting symlinks in `.next/node_modules/` point to `../../../../node_modules/.pnpm/...` (workspace root) and must be redirected to `../../node_modules/.pnpm/...` (deploy output) via the `assemble-prod.sh` rewrite step.
+- **Implications**: Any package imported at module-level in an SSR-rendered code path (component, hook, utility, server service) will be externalised and must be in `dependencies`.
+
+### pnpm deploy --legacy output structure
+
+- **Context**: Does `--legacy` produce a flat (hoisted) or pnpm-native node_modules?
+- **Findings**:
+  - `pnpm deploy --legacy` in pnpm v10 still produces a pnpm-native structure with `node_modules/.pnpm/` and symlinks. The `--legacy` flag only bypasses the `inject-workspace-packages` gate; it does NOT force hoisting.
+  - Transitive deps (e.g., `use-sync-external-store`, `dequal` from `swr`) are in `.pnpm/` but NOT at the top-level. The `cp -rL` approach failed because physical copies in `.next/node_modules/` lose the `.pnpm/` sibling resolution context.
+  - The correct fix is symlink rewriting in `assemble-prod.sh`: `../../../../node_modules/.pnpm/` → `../../node_modules/.pnpm/`.
+- **Implications**: The symlink rewrite in `assemble-prod.sh` is essential and must not be replaced with `cp -rL`.
+
+### @emoji-mart/data — server-side import analysis
+
+- **Context**: Can the server-side import in `emoji.ts` be removed or replaced?
+- **Sources Consulted**: `apps/app/src/services/renderer/remark-plugins/emoji.ts`
+- **Findings**:
+  - The plugin performs `import emojiData from '@emoji-mart/data/sets/15/native.json'` and accesses only `emojiData.emojis[$1]?.skins[0].native`.
+  - The data structure needed is: `{ emojis: { [name: string]: { skins: [{ native: string }] } } }`.
+  - This is a static JSON lookup — no runtime behaviour from the package, only data.
+  - Alternative: A minimal bundled static file (`emoji-native-lookup.json`) containing only the `name → native emoji` mapping could replace the full `@emoji-mart/data` package.
+  - Effort: moderate (requires a build-time extraction script or manual curation); risk: emoji data staleness.
+- **Implications**: Technically feasible to replace with a static file, but adds maintenance overhead. Acceptable to keep as `dependencies` for Phase 1; defer decision to Phase 2 investigation.
+
+### bootstrap — dynamic import analysis
+
+- **Context**: Why does `bootstrap` appear in `.next/node_modules/` when the import appears to be dynamic?
+- **Sources Consulted**: `apps/app/src/pages/_app.page.tsx:93`
+- **Findings**:
+  - `import('bootstrap/dist/js/bootstrap')` is a dynamic `import()` expression, not a static `import` statement.
+  - If called inside a `useEffect`, it would be browser-only and Turbopack should not externalise it for SSR.
+  - Needs verification: check whether Turbopack traces the `import()` call site (component level vs `useEffect`) and whether it appears in `.next/node_modules/` after a build without bootstrap in `dependencies`.
+- **Implications**: Bootstrap may be safely reverted to `devDependencies` if the `import()` is confirmed to be inside a browser-only lifecycle hook. Flag for Phase 4 investigation.
+
+### socket.io-client — mixed import pattern
+
+- **Context**: `global-socket.ts` uses `await import('socket.io-client')` (dynamic, browser-safe); `features/admin/states/socket-io.ts` uses `import io from 'socket.io-client'` (static, externalised by Turbopack).
+- **Findings**:
+  - The admin socket's static import is the cause of externalisation.
+  - Refactoring to `const { default: io } = await import('socket.io-client')` (matching `global-socket.ts` pattern) would remove the static import from the SSR code path.
+  - Pattern precedent exists in `global-socket.ts` — low-risk refactor.
+- **Implications**: After refactoring admin socket to dynamic import, `socket.io-client` should no longer appear in `.next/node_modules/` and can revert to `devDependencies`.
+
+### react-toastify — partial ssr:false coverage
+
+- **Context**: `ToastContainer` is guarded by `dynamic({ ssr: false })` in `RawLayout.tsx`, but `toastr.ts` imports `{ toast }` and type imports statically.
+- **Findings**:
+  - Even if `ToastContainer` is client-only, the `toast` function import in `toastr.ts` is a static module-level import, causing Turbopack to externalise `react-toastify`.
+  - Refactoring `toast` calls to use `await import('react-toastify').then(m => m.toast(...))` would remove the static import. However, `toastr.ts` is a utility used broadly; async toast calls would change its API surface.
+  - Alternative: Accept `react-toastify` as a `dependencies` entry given its small size.
+- **Implications**: The type imports (`ToastContent`, `ToastOptions`) are erased at runtime and do not cause externalisation; only the value import `{ toast }` matters. Simplest path: keep in `dependencies`.
+
+### Packages with no src/ imports
+
+- **Context**: `i18next-http-backend`, `i18next-localstorage-backend`, `react-dropzone` — no imports found in `apps/app/src/`.
+- **Findings**:
+  - These may be imported in shared packages (`@growi/editor`, `@growi/core`) that are listed as workspace dependencies.
+  - Alternatively, they may be historical entries added but never used in `apps/app` directly.
+  - If they do NOT appear in `.next/node_modules/` after Phase 1 build, they are safe to remove from `devDependencies` entirely.
+- **Implications**: Investigate post-Phase 1. If not in `.next/node_modules/` and not in prod `node_modules`, remove from `devDependencies`.
+
+---
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks | Notes |
+|--------|-------------|-----------|-------|-------|
+| Move-all-to-deps (flat fix) | Move all 23 packages to `dependencies` and stop | Immediate fix, zero code changes | Large production artifact, wrong semantics | Acceptable as Phase 1 baseline only |
+| Phased minimisation | Phase 1 fix + systematic revert via ssr:false / dynamic import / removal | Minimal production artifact, correct semantics | More effort, requires per-package verification | **Selected approach** |
+| cp -rL (.next/node_modules) | Resolve symlinks to physical files | Self-contained | Breaks transitive dep resolution (use-sync-external-store issue) | Rejected — symlink rewrite is correct approach |
+
+---
+
+## Design Decisions
+
+### Decision: Symlink Rewrite over cp -rL
+
+- **Context**: `.next/node_modules/` symlinks point to workspace root `.pnpm/`; release image only has `apps/app/node_modules/.pnpm/`.
+- **Alternatives Considered**:
+  1. `cp -rL` — copy physical files, loses pnpm sibling resolution
+  2. Symlink rewrite (`../../../../ → ../../`) — updates symlink targets to deployed `.pnpm/`
+- **Selected Approach**: Symlink rewrite in `assemble-prod.sh`
+- **Rationale**: Preserves pnpm's isolated structure; transitive deps (e.g., `use-sync-external-store` for `swr`) remain resolvable via `.pnpm/` sibling pattern.
+- **Trade-offs**: Requires `find -maxdepth 2` to handle both depth-1 and depth-2 symlinks under `@scope/` directories.
+
+### Decision: Phased Revert Strategy
+
+- **Context**: Rather than leaving all 23 in `dependencies` permanently, systematically revert where safe.
+- **Selected Approach**: Phase 1 (all to deps) → Phase 2 (server-side import removal) → Phase 3 (ssr:false) → Phase 4 (ambiguous) → Phase 5 (validate & document)
+- **Rationale**: Each phase is independently verifiable; Phase 1 unblocks CI immediately.
+
+### Decision: @emoji-mart/data — Accept as Production Dependency (Phase 1)
+
+- **Context**: Static JSON import used for server-side emoji processing.
+- **Selected Approach**: Move to `dependencies`; defer extraction of a static lookup file to Phase 2 investigation.
+- **Rationale**: The data is genuinely needed server-side; extraction adds maintenance overhead that may not be worth the artifact size saving.
+
+---
+
+## Risks & Mitigations
+
+- **Phase 3 ssr:false causes hydration mismatch** — Mitigation: test each wrapped component in browser before reverting the package; use React hydration warnings as signal.
+- **Phase 4 admin socket refactor breaks admin panel** — Mitigation: existing `global-socket.ts` dynamic pattern serves as verified template; unit test admin socket atom.
+- **Turbopack version change alters externalisation heuristics** — Mitigation: `assemble-prod.sh` includes a post-rewrite check via production server smoke test; Req 5.3 enforces no devDeps in `.next/node_modules/`.
+- **Phantom packages (i18next-*, react-dropzone) are transitive** — Mitigation: verify by checking `.next/node_modules/` contents post-Phase-1; remove only after confirming absence.
+
+---
+
+## References
+
+- pnpm deploy documentation — `pnpm deploy` flags and node-linker behaviour
+- Next.js Pages Router SSR — all pages render server-side by default; `dynamic({ ssr: false })` is the only opt-out
+- Turbopack externalisation — packages in `.next/node_modules/` are loaded at runtime, not bundled

+ 22 - 0
.kiro/specs/optimise-deps-for-prod/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "optimise-deps-for-prod",
+  "created_at": "2026-03-12T05:00:00Z",
+  "updated_at": "2026-03-12T05:20:00Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": false
+}

+ 136 - 0
.kiro/specs/optimise-deps-for-prod/tasks.md

@@ -0,0 +1,136 @@
+# Implementation Plan
+
+## Task Overview
+
+| Phase | Major Task | Sub-tasks | Requirements |
+|-------|-----------|-----------|--------------|
+| 1 | Baseline fix — move 23 packages | 1.1, 1.2 | 1.1–1.5 |
+| 2 | Eliminate `@emoji-mart/data` server import | 2.1, 2.2 | 2.1–2.4 |
+| 3 | Apply `ssr: false` to Group 1 components | 3.1–3.5 | 3.1–3.5 |
+| 4 | Resolve ambiguous packages | 4.1–4.5 | 4.1–4.5 |
+| 5 | Final validation and documentation | 5.1, 5.2 | 5.1–5.5 |
+
+---
+
+- [ ] 1. Move all 23 Turbopack-externalised packages from `devDependencies` to `dependencies`
+
+- [ ] 1.1 Edit `apps/app/package.json` to reclassify all 23 packages
+  - Move the following entries from the `devDependencies` section to the `dependencies` section, preserving alphabetical order within each section: `@codemirror/state`, `@emoji-mart/data`, `@handsontable/react`, `@headless-tree/core`, `@headless-tree/react`, `@tanstack/react-virtual`, `bootstrap`, `diff2html`, `downshift`, `fastest-levenshtein`, `fslightbox-react`, `i18next-http-backend`, `i18next-localstorage-backend`, `pretty-bytes`, `react-copy-to-clipboard`, `react-dnd`, `react-dnd-html5-backend`, `react-dropzone`, `react-hook-form`, `react-input-autosize`, `react-toastify`, `simplebar-react`, `socket.io-client`
+  - Run `pnpm install --frozen-lockfile` from the monorepo root after editing to verify the lock file remains valid; if it fails (lock file mismatch), run `pnpm install` to regenerate it
+  - _Requirements: 1.1_
+
+- [ ] 1.2 Verify the production server starts cleanly after the package reclassification
+  - Run `bash apps/app/bin/assemble-prod.sh` from the monorepo root to produce the release artifact
+  - Start the production server with `pnpm run server` and confirm no `ERR_MODULE_NOT_FOUND` or `Failed to load external module` errors appear in stdout
+  - Send a GET request to `/login` and assert HTTP 200; confirm the server logs show no SSR errors for the login page
+  - _Requirements: 1.2, 1.3, 1.4, 1.5_
+
+---
+
+- [ ] 2. Replace the `@emoji-mart/data` runtime dependency with a bundled static lookup file
+
+- [ ] 2.1 Extract the minimal emoji lookup data from `@emoji-mart/data` into a static JSON file
+  - Inspect `apps/app/src/services/renderer/remark-plugins/emoji.ts` to confirm the only fields consumed are `emojiData.emojis[name]?.skins[0].native`
+  - Write a one-off extraction script (run from `apps/app/`) that reads `node_modules/@emoji-mart/data/sets/15/native.json`, extracts a `Record<string, { skins: [{ native: string }] }>` map, and writes it to `apps/app/src/services/renderer/remark-plugins/emoji-native-lookup.json`
+  - Run the extraction script and commit the generated JSON file alongside the script
+  - Document in a comment above the script that it must be re-run whenever `@emoji-mart/data` is upgraded (Req 2.1 investigation outcome)
+  - _Requirements: 2.1_
+
+- [ ] 2.2 Refactor `emoji.ts` to use the static lookup file and revert `@emoji-mart/data` to `devDependencies`
+  - Replace the `import emojiData from '@emoji-mart/data/sets/15/native.json'` statement in `emoji.ts` with an import of the newly created `./emoji-native-lookup.json`
+  - Run `turbo run build --filter @growi/app` and confirm no build errors
+  - Run `bash apps/app/bin/assemble-prod.sh` and confirm `@emoji-mart/data` no longer appears in `apps/app/.next/node_modules/`
+  - Verify emoji rendering is intact: start the production server and render a page containing `:+1:`, `:tada:`, and `:rocket:` shortcodes; assert native emoji characters appear in the HTML output
+  - Move `@emoji-mart/data` from `dependencies` back to `devDependencies` in `apps/app/package.json`; if removal is not viable, document the reason as a comment in `package.json` and leave it in `dependencies` per Req 2.4
+  - _Requirements: 2.2, 2.3, 2.4_
+
+---
+
+- [ ] 3. Apply `dynamic({ ssr: false })` to eligible Group 1 components
+
+- [ ] 3.1 (P) Wrap `LightBox.tsx` import with `dynamic({ ssr: false })` and verify `fslightbox-react` leaves `dependencies`
+  - Locate the consuming component file that imports from `fslightbox-react` and wrap the import using `const LightBox = dynamic(() => import('path/to/LightBox'), { ssr: false })`
+  - Run a production build and confirm `fslightbox-react` no longer appears in `apps/app/.next/node_modules/`
+  - Test in the browser: open a page with an image lightbox; confirm the lightbox opens normally with no React hydration warnings in the browser console
+  - Move `fslightbox-react` from `dependencies` to `devDependencies`; if hydration errors appear, revert and document as justified production dependency per Req 3.5
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
+
+- [ ] 3.2 (P) Wrap `RevisionDiff.tsx` import with `dynamic({ ssr: false })` and verify `diff2html` leaves `dependencies`
+  - Locate the consuming component for `diff2html` and wrap the import using `dynamic(() => import('...'), { ssr: false })`
+  - Run a production build and confirm `diff2html` no longer appears in `apps/app/.next/node_modules/`
+  - Test in the browser: navigate to a page revision diff view; confirm diff output renders correctly with no hydration warnings
+  - Move `diff2html` from `dependencies` to `devDependencies`; if hydration errors appear, revert and document per Req 3.5
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
+
+- [ ] 3.3 (P) Wrap the DnD provider in `PageTree` with `dynamic({ ssr: false })` and verify `react-dnd` / `react-dnd-html5-backend` leave `dependencies`
+  - Identify the component that wraps the page tree in a `DndProvider`; apply `dynamic(() => import('...'), { ssr: false })` to that provider wrapper only, not to the tree content itself (preserving SSR for page titles)
+  - Run a production build and confirm neither `react-dnd` nor `react-dnd-html5-backend` appears in `apps/app/.next/node_modules/`
+  - Test in the browser: verify page tree drag-and-drop reordering works with no hydration warnings or layout shift
+  - Move `react-dnd` and `react-dnd-html5-backend` from `dependencies` to `devDependencies`; if issues arise, revert and document per Req 3.5
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
+
+- [ ] 3.4 (P) Confirm `HandsontableModal` already uses a `dynamic` import and verify `@handsontable/react` leave `dependencies`
+  - Inspect the `HandsontableModal.tsx` import chain to confirm whether `@handsontable/react` is already loaded via `dynamic()` with `ssr: false`
+  - Run a production build and check whether `@handsontable/react` appears in `apps/app/.next/node_modules/`
+  - If it is absent: move `@handsontable/react` from `dependencies` to `devDependencies`
+  - If it still appears: apply `dynamic({ ssr: false })` wrapping, retest, then move to `devDependencies` if cleared; otherwise document as justified production dependency per Req 3.5
+  - _Requirements: 3.1, 3.2, 3.4, 3.5_
+
+- [ ] 3.5 Run a consolidated production build verification after all Group 1 wrapping changes
+  - Run `bash apps/app/bin/assemble-prod.sh` and start `pnpm run server`; confirm the server starts without errors and `/login` returns HTTP 200
+  - Check the browser console on pages that contain the wrapped components (lightbox, diff viewer, page tree, Handsontable modal) and assert zero React hydration warnings
+  - Confirm each successfully wrapped package no longer appears in `apps/app/.next/node_modules/` (see design for per-package revert conditions)
+  - _Requirements: 3.3, 3.4, 3.5_
+
+---
+
+- [ ] 4. Resolve ambiguous and phantom package classifications
+
+- [ ] 4.1 (P) Confirm `react-toastify` must remain in `dependencies`
+  - Inspect `apps/app/src/client/util/toastr.ts` and verify it uses a static module-level `import { toast } from 'react-toastify'`
+  - Determine whether this file is reachable from any SSR code path (Pages Router page, `_app.page.tsx`, or a server utility); if yes, document `react-toastify` as a justified production dependency
+  - Note the result in a comment in `apps/app/package.json` next to the `react-toastify` entry
+  - _Requirements: 4.1_
+
+- [ ] 4.2 (P) Refactor `admin/states/socket-io.ts` to use a dynamic import and verify `socket.io-client` leaves `dependencies`
+  - Replace the static `import io from 'socket.io-client'` with a dynamic import expression inside the Jotai atom initialiser, matching the pattern already used in `states/socket-io/global-socket.ts`
+  - Run a production build and confirm `socket.io-client` no longer appears in `apps/app/.next/node_modules/`
+  - Test in the browser: open the admin panel and confirm the admin Socket.IO connection establishes successfully (WebSocket upgrade visible in browser DevTools Network tab)
+  - If the refactor is successful and the package is absent from `.next/node_modules/`, move `socket.io-client` from `dependencies` to `devDependencies`; if admin socket consumers require synchronous access at page load, document as justified production dependency per Req 4.2
+  - _Requirements: 4.2_
+
+- [ ] 4.3 (P) Verify whether `bootstrap` JS `import()` is browser-only and classify accordingly
+  - Inspect `apps/app/src/pages/_app.page.tsx` to find the `import('bootstrap/dist/js/bootstrap')` expression and confirm whether it is inside a `useEffect` hook (browser-only) or at module level (SSR path)
+  - Run a production build and note whether `bootstrap` appears in `apps/app/.next/node_modules/`
+  - If the import is inside `useEffect` and `bootstrap` does not appear in `.next/node_modules/`: move `bootstrap` from `dependencies` to `devDependencies`
+  - If `bootstrap` still appears in `.next/node_modules/`: leave in `dependencies` and document the reason per Req 4.3
+  - _Requirements: 4.3_
+
+- [ ] 4.4 (P) Investigate phantom packages and remove or reclassify them
+  - After completing Phase 1 (Task 1), run `bash apps/app/bin/assemble-prod.sh`, then list `apps/app/.next/node_modules/` and check whether `i18next-http-backend`, `i18next-localstorage-backend`, and `react-dropzone` are present
+  - Search `apps/app/src/` for any direct imports of these three packages to determine whether they are genuinely unused
+  - If a package is absent from `.next/node_modules/` and has no direct imports: remove it from `devDependencies` entirely (it is an unused dependency)
+  - If a package appears in `.next/node_modules/` via a transitive chain: leave it in `dependencies` (moved there in Task 1.1) and document the transitive source per Req 4.4
+  - _Requirements: 4.4_
+
+- [ ] 4.5 Apply all Phase 4 package.json classification changes and run consolidated verification
+  - Apply all `devDependencies` / `dependencies` moves identified in tasks 4.1–4.4
+  - Run `pnpm install --frozen-lockfile` to verify lock file integrity
+  - Run `bash apps/app/bin/assemble-prod.sh` and start `pnpm run server`; confirm the server starts without errors and `/login` returns HTTP 200
+  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
+
+---
+
+- [ ] 5. Final validation and documentation
+
+- [ ] 5.1 Verify that every `.next/node_modules/` symlink resolves correctly in the release artifact
+  - Run `bash apps/app/bin/assemble-prod.sh` to produce the final artifact
+  - Enumerate every symlink under `apps/app/.next/node_modules/` with `find apps/app/.next/node_modules -maxdepth 2 -type l` and assert that each target path exists under `apps/app/node_modules/.pnpm/` (no broken symlinks)
+  - Assert that no package listed in `devDependencies` in `apps/app/package.json` appears in `apps/app/.next/node_modules/` (no classification regression)
+  - Start `pnpm run server` and confirm HTTP 200 on `/login` with no SSR errors in the server log
+  - _Requirements: 5.1, 5.2, 5.3, 5.4_
+
+- [ ] 5.2 Add Turbopack externalisation rule documentation
+  - Add a comment block in `apps/app/package.json` above the `dependencies` section (or in a neighbouring `ARCHITECTURE.md` / steering document) explaining: any package that is imported in SSR-rendered code (Pages Router components, `_app.page.tsx`, server-side utilities) must be in `dependencies`, not `devDependencies`, because Turbopack externalises such packages to `.next/node_modules/` which are not present in the `pnpm deploy --prod` output if the package is listed under `devDependencies`
+  - Update `.kiro/steering/tech.md` to reference this rule under the "Import Optimization Principles" section
+  - _Requirements: 5.5_