diff --git a/.cursor/mcp.json b/.cursor/mcp.json
index 700113020..da39e4ffa 100644
--- a/.cursor/mcp.json
+++ b/.cursor/mcp.json
@@ -1,3 +1,3 @@
{
"mcpServers": {}
-}
\ No newline at end of file
+}
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 17d262524..362875e00 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -13,6 +13,7 @@ We will acknowledge receipt and work to address the issue as quickly as possible
## Scope
This policy applies to:
+
- The CoRATES web application
- Cloudflare Workers and Durable Objects
- Client-side synchronization and storage logic
diff --git a/docs/architecture/diagrams/01-package-architecture.md b/docs/architecture/diagrams/01-package-architecture.md
index 33441ef80..bd6d06ea8 100644
--- a/docs/architecture/diagrams/01-package-architecture.md
+++ b/docs/architecture/diagrams/01-package-architecture.md
@@ -31,11 +31,11 @@ graph TB
## Package Details
-| Package | Purpose | Tech |
-| --------- | ----------------------------------- | ------------------------ |
-| `web` | Main SolidJS application | SolidJS, Vite, Tailwind |
-| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers |
-| `landing` | Marketing site (includes web app) | SolidStart |
-| `ui` | Shared component library | SolidJS, Zag.js |
-| `shared` | Shared error definitions and utilities | TypeScript |
-| `mcp` | Development tooling (docs, linting) | Node.js |
+| Package | Purpose | Tech |
+| --------- | -------------------------------------- | ------------------------ |
+| `web` | Main SolidJS application | SolidJS, Vite, Tailwind |
+| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers |
+| `landing` | Marketing site (includes web app) | SolidStart |
+| `ui` | Shared component library | SolidJS, Zag.js |
+| `shared` | Shared error definitions and utilities | TypeScript |
+| `mcp` | Development tooling (docs, linting) | Node.js |
diff --git a/docs/architecture/diagrams/04-data-model.md b/docs/architecture/diagrams/04-data-model.md
index a5edee244..6a9272035 100644
--- a/docs/architecture/diagrams/04-data-model.md
+++ b/docs/architecture/diagrams/04-data-model.md
@@ -66,17 +66,18 @@ Individual response to a checklist question. Stored entirely in Durable Objects
## Storage Split
-| Entity | Storage | Reason |
-| ---------------------------- | -------------------------- | ----------------------------------- |
-| Users | D1 (SQLite) | User accounts, authentication |
-| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control |
-| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) |
-| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration |
-| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
-| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
-| PDFs | R2 | Large binary files |
+| Entity | Storage | Reason |
+| ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
+| Users | D1 (SQLite) | User accounts, authentication |
+| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control |
+| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) |
+| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration |
+| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
+| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
+| PDFs | R2 | Large binary files |
**Architecture Notes**:
+
- **D1** stores project metadata (name, description, etc.) and membership relationships. This is the source of truth for authorization and access control.
- **Durable Objects** store the actual project content (studies, checklists, answers) in a Yjs Document, plus synced copies of metadata and members for real-time collaborative access.
- When a project is created, it's written to D1 first, then metadata is synced to the Durable Object.
diff --git a/docs/architecture/diagrams/05-frontend-routes.md b/docs/architecture/diagrams/05-frontend-routes.md
index 2e1129756..284c0e73a 100644
--- a/docs/architecture/diagrams/05-frontend-routes.md
+++ b/docs/architecture/diagrams/05-frontend-routes.md
@@ -43,13 +43,13 @@ flowchart TD
No authentication required. Redirects to dashboard if already logged in.
-| Route | Component | Purpose |
-| ----------------- | -------------- | ------------------------- |
-| `/signin` | SignIn | Email/password login |
-| `/signup` | SignUp | New account creation |
-| `/check-email` | CheckEmail | Email verification prompt |
-| `/complete-profile` | CompleteProfile | Initial profile setup |
-| `/reset-password` | ResetPassword | Password recovery |
+| Route | Component | Purpose |
+| ------------------- | --------------- | ------------------------- |
+| `/signin` | SignIn | Email/password login |
+| `/signup` | SignUp | New account creation |
+| `/check-email` | CheckEmail | Email verification prompt |
+| `/complete-profile` | CompleteProfile | Initial profile setup |
+| `/reset-password` | ResetPassword | Password recovery |
### Protected Routes (ProtectedGuard)
@@ -64,16 +64,16 @@ Requires authentication. Redirects to signin if not logged in.
### Project Routes
-| Route | Component | Purpose |
-| ------------------------------ | --------------------- | ------------------------------ |
-| `/projects/:projectId` | ProjectView | Project overview, studies list |
-| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment |
+| Route | Component | Purpose |
+| -------------------------------------------- | --------------------- | ------------------------------ |
+| `/projects/:projectId` | ProjectView | Project overview, studies list |
+| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment |
| `/.../reconcile/:checklist1Id/:checklist2Id` | ReconciliationWrapper | Compare two checklists |
### Local Routes
Routes for local-only checklists (no authentication required, stored in IndexedDB).
-| Route | Component | Purpose |
-| ------------------------ | ------------------ | ---------------------------- |
-| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor |
+| Route | Component | Purpose |
+| ------------------------- | ------------------ | --------------------------- |
+| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor |
diff --git a/docs/architecture/diagrams/06-api-routes.md b/docs/architecture/diagrams/06-api-routes.md
index f19b665b0..0c7760d45 100644
--- a/docs/architecture/diagrams/06-api-routes.md
+++ b/docs/architecture/diagrams/06-api-routes.md
@@ -89,6 +89,7 @@ Handled by BetterAuth. Includes signin, signup, session management.
### Billing (`/api/billing/*`)
Stripe integration for subscriptions and payments:
+
- `GET /api/billing/subscription` - Get user subscription
- `POST /api/billing/checkout` - Create Stripe checkout session
- `POST /api/billing/portal` - Create Stripe customer portal session
@@ -113,6 +114,7 @@ Google Drive integration endpoints for importing documents.
### Database (`/api/db/*`)
Development/diagnostic endpoints:
+
- `GET /api/db/users` - List users (development only)
### Durable Object Routes
diff --git a/eslint.config.js b/eslint.config.js
index 7e59694a3..3e8149a1a 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -30,6 +30,7 @@ export default [
globals: {
// Browser globals
+ performance: 'readonly',
ReadableStream: 'readonly',
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
diff --git a/package.json b/package.json
index 5d666f69b..a746240b4 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"deps:update:latest": "pnpm -r up --latest",
"user:make-admin:prod": "pnpm --filter @corates/workers run user:make-admin:prod",
"user:make-admin:local": "pnpm --filter @corates/workers run user:make-admin:local",
- "loc": "bash ./scripts/loc-report.sh",
+ "loc": "node ./scripts/loc-report.mjs",
"docs": "npx serve ./docs/architecture -l 3020",
"openapi": "pnpm --filter @corates/workers run openapi:generate"
},
diff --git a/packages/ui/src/zag/Tooltip.jsx b/packages/ui/src/zag/Tooltip.jsx
index 68fe6ae8d..3a26bcc8b 100644
--- a/packages/ui/src/zag/Tooltip.jsx
+++ b/packages/ui/src/zag/Tooltip.jsx
@@ -11,6 +11,23 @@ export function Tooltip(props) {
placement: props.placement || 'top',
gutter: 8,
strategy: 'fixed', // Use fixed positioning to avoid stacking context issues
+ flip: true,
+ // shift: true,
+ boundary: () => {
+ // Get the main navbar element to use as a boundary
+ const navbar = document.querySelector('nav[class*="sticky"]');
+ if (navbar) {
+ const navbarRect = navbar.getBoundingClientRect();
+ const navbarBottom = navbarRect.bottom;
+ return {
+ x: 0,
+ y: navbarBottom,
+ width: window.innerWidth,
+ height: window.innerHeight - navbarBottom,
+ };
+ }
+ return 'viewport';
+ },
},
openDelay: props.openDelay ?? 100,
closeDelay: props.closeDelay ?? 100,
diff --git a/packages/web/.gitignore b/packages/web/.gitignore
index cd541b256..4039865a4 100644
--- a/packages/web/.gitignore
+++ b/packages/web/.gitignore
@@ -11,6 +11,7 @@
# testing
/coverage
+bundle-analysis.html
# production
/build
diff --git a/packages/web/package.json b/packages/web/package.json
index a5377ba27..eef8993b2 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -12,7 +12,8 @@
"deploy": "pnpm run build",
"test:ui": "vitest --ui --ui.port=51234",
"test": "vitest run --reporter=verbose",
- "format": "prettier --write ."
+ "format": "prettier --write .",
+ "analyze": "vite build --mode analyze && source-map-explorer dist/assets/index-*.js --no-border-checks"
},
"dependencies": {
"@corates/shared": "workspace:*",
@@ -36,7 +37,10 @@
"jsdom": "^27.3.0",
"prettier": "^3.7.4",
"puppeteer": "^24.34.0",
+ "rollup-plugin-visualizer": "^6.0.5",
+ "source-map-explorer": "^2.5.3",
"tailwindcss": "^4.1.18",
+ "terser": "^5.44.1",
"vite": "^7.3.0",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.16"
diff --git a/packages/web/src/api/pdf-api.js b/packages/web/src/api/pdf-api.js
index b69a64256..401e72bb8 100644
--- a/packages/web/src/api/pdf-api.js
+++ b/packages/web/src/api/pdf-api.js
@@ -40,25 +40,25 @@ export async function fetchPdfViaProxy(url) {
export async function uploadPdf(projectId, studyId, file, fileName = null) {
const url = `${API_BASE}/api/projects/${projectId}/studies/${studyId}/pdfs`;
- let body;
- let headers = {};
+ // Always use FormData for consistency and better browser streaming support
+ // This works for both File objects and ArrayBuffers (by converting ArrayBuffer to Blob)
+ const formData = new FormData();
if (file instanceof File) {
- const formData = new FormData();
formData.append('file', file);
- body = formData;
} else {
- // ArrayBuffer
- body = file;
- headers['Content-Type'] = 'application/pdf';
- headers['X-File-Name'] = fileName || 'document.pdf';
+ // Convert ArrayBuffer to Blob so we can use FormData
+ const blob = new Blob([file], { type: 'application/pdf' });
+ const fileObj = new File([blob], fileName || 'document.pdf', { type: 'application/pdf' });
+ formData.append('file', fileObj);
}
+ // Don't set Content-Type - browser will set it automatically with boundary for multipart/form-data
+ // Don't set Content-Length - browser will calculate it automatically for FormData
const response = await fetch(url, {
method: 'POST',
credentials: 'include',
- headers,
- body,
+ body: formData,
});
if (!response.ok) {
diff --git a/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx b/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx
index 461e06569..72a0296bf 100644
--- a/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx
+++ b/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx
@@ -2,7 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js';
import { AMSTAR_CHECKLIST } from '@/AMSTAR2/checklist-map.js';
import { createChecklist as createAMSTAR2Checklist } from '@/AMSTAR2/checklist.js';
import { FaSolidCircleInfo } from 'solid-icons/fa';
-import { Tooltip, FloatingPanel } from '@corates/ui';
+import { Tooltip } from '@corates/ui';
import NoteEditor from '@checklist-ui/common/NoteEditor.jsx';
export function Question1(props) {
@@ -695,43 +695,13 @@ function StandardQuestion(props) {
}
function QuestionInfo(props) {
- const [showInfo, setShowInfo] = createSignal(false);
- const [panelPos, setPanelPos] = createSignal({ x: 0, y: 0 });
- let containerRef = () => props.containerRef;
-
- function openInfoPanel() {
- if (containerRef()) {
- const btnRect = containerRef().getBoundingClientRect();
- setPanelPos({
- x: btnRect.left + 20,
- y: btnRect.top - 20,
- });
- }
- setShowInfo(true);
- }
-
- let question = () => props.question.text.split('.')[0] + '. Learn more';
-
return (
<>
- setShowInfo(details.open)}
- position={panelPos()}
- onPositionChange={pos => setPanelPos(pos.position)}
- showMaximize={false}
- >
- {props.question.info}
-
>
diff --git a/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx b/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx
index 3b146d3db..52e9e8947 100644
--- a/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx
+++ b/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx
@@ -15,6 +15,7 @@ import {
} from '@/AMSTAR2/checklist-compare.js';
import ReconciliationQuestionPage from './ReconciliationQuestionPage.jsx';
import SummaryView from './SummaryView.jsx';
+import { createChecklist } from '@/AMSTAR2/checklist.js';
export default function ChecklistReconciliation(props) {
// props.checklist1 - First reviewer's checklist data
@@ -285,7 +286,6 @@ export default function ChecklistReconciliation(props) {
if (!props.updateChecklistAnswer) return;
// Get default empty structure from createChecklist
- const { createChecklist } = await import('@/AMSTAR2/checklist.js');
const defaultChecklist = createChecklist({
name: 'temp',
id: 'temp',
diff --git a/packages/web/src/stores/projectActionsStore/pdfs.js b/packages/web/src/stores/projectActionsStore/pdfs.js
index 97547a493..31e1293de 100644
--- a/packages/web/src/stores/projectActionsStore/pdfs.js
+++ b/packages/web/src/stores/projectActionsStore/pdfs.js
@@ -188,6 +188,7 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur
/**
* Delete a PDF from a study (uses active project)
+ * Ensures cleanup from R2, IndexedDB, and Y.js with proper error handling
*/
async function deletePdfFromStudy(studyId, pdf) {
const projectId = getActiveProjectId();
@@ -198,12 +199,43 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur
throw new Error('Not connected to project');
}
+ let r2Deleted = false;
+
try {
- await deletePdf(projectId, studyId, pdf.fileName);
- ops.removePdfFromStudy(studyId, pdf.id);
- removeCachedPdf(projectId, studyId, pdf.fileName).catch(err =>
- console.warn('Failed to remove PDF from cache:', err),
- );
+ // Step 1: Delete from R2 storage first
+ try {
+ await deletePdf(projectId, studyId, pdf.fileName);
+ r2Deleted = true;
+ } catch (r2Err) {
+ console.error('Failed to delete PDF from R2:', r2Err);
+ // Still attempt IndexedDB cleanup even if R2 deletion fails
+ }
+
+ // Step 2: Always attempt IndexedDB cleanup, even if previous step failed
+ try {
+ await removeCachedPdf(projectId, studyId, pdf.fileName);
+ } catch (cacheErr) {
+ console.warn('Failed to remove PDF from IndexedDB cache:', cacheErr);
+ // Don't throw - cache cleanup failure shouldn't block the operation
+ }
+
+ // Step 3: Remove from Y.js only if R2 deletion succeeded
+ // This prevents inconsistencies where PDF exists in R2 but not in Y.js
+ if (r2Deleted) {
+ try {
+ ops.removePdfFromStudy(studyId, pdf.id);
+ } catch (yjsErr) {
+ console.error('Failed to remove PDF from Y.js:', yjsErr);
+ // R2 deletion succeeded but Y.js removal failed - log warning
+ // The PDF will remain in Y.js but is deleted from R2
+ throw new Error('PDF deleted from R2 but failed to remove from study');
+ }
+ }
+
+ // If R2 deletion failed, throw to indicate the operation didn't fully succeed
+ if (!r2Deleted) {
+ throw new Error('Failed to delete PDF from R2 storage');
+ }
} catch (err) {
console.error('Error deleting PDF:', err);
throw err;
diff --git a/packages/web/src/stores/projectActionsStore/studies.js b/packages/web/src/stores/projectActionsStore/studies.js
index c07c5b095..367e4394d 100644
--- a/packages/web/src/stores/projectActionsStore/studies.js
+++ b/packages/web/src/stores/projectActionsStore/studies.js
@@ -3,11 +3,12 @@
*/
import { uploadPdf, fetchPdfViaProxy, downloadPdf, deletePdf } from '@api/pdf-api.js';
-import { cachePdf } from '@primitives/pdfCache.js';
+import { cachePdf, clearStudyCache } from '@primitives/pdfCache.js';
import { showToast } from '@corates/ui';
import { importFromGoogleDrive } from '@api/google-drive.js';
import { extractPdfDoi, extractPdfTitle } from '@/lib/pdfUtils.js';
import { fetchFromDOI } from '@/lib/referenceLookup.js';
+import projectStore from '../projectStore.js';
// ============================================================================
// Helpers
@@ -256,15 +257,41 @@ export function createStudyActions(getActiveConnection, getActiveProjectId, getC
/**
* Delete a study (low-level, no confirmation) (uses active project)
+ * Cleans up all associated PDFs from R2 and IndexedDB before deleting from Y.js
*/
- function deleteStudy(studyId) {
+ async function deleteStudy(studyId) {
+ const projectId = getActiveProjectId();
const ops = getActiveConnection();
if (!ops?.deleteStudy) {
console.error('No connection for active project');
showToast.error('Delete Failed', 'Not connected to project');
return;
}
+
try {
+ // Get study data to access PDFs before deletion
+ const study = projectStore.getStudy(projectId, studyId);
+ const pdfs = study?.pdfs || [];
+
+ // Delete each PDF from R2 storage
+ if (pdfs.length > 0) {
+ const deletePromises = pdfs.map(pdf => {
+ if (!pdf?.fileName) return Promise.resolve();
+ return deletePdf(projectId, studyId, pdf.fileName).catch(err => {
+ // Log but don't block - continue with other PDFs
+ console.warn(`Failed to delete PDF ${pdf.fileName} from R2:`, err);
+ });
+ });
+ await Promise.all(deletePromises);
+ }
+
+ // Clear all PDFs for this study from IndexedDB
+ await clearStudyCache(projectId, studyId).catch(err => {
+ // Log but don't block - continue with Y.js deletion
+ console.warn('Failed to clear study PDF cache from IndexedDB:', err);
+ });
+
+ // Finally, delete the study from Y.js
ops.deleteStudy(studyId);
} catch (err) {
console.error('Error deleting study:', err);
diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js
index 68c9885b8..70b2e9ed5 100644
--- a/packages/web/vite.config.js
+++ b/packages/web/vite.config.js
@@ -3,7 +3,7 @@ import solidPlugin from 'vite-plugin-solid';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASEPATH || '/',
resolve: {
alias: {
@@ -27,6 +27,13 @@ export default defineConfig({
plugins: [solidPlugin(), tailwindcss()],
build: {
target: ['es2020', 'safari14'],
+ minify: mode === 'analyze' ? 'terser' : 'esbuild',
+ sourcemap: mode === 'analyze',
+ terserOptions: {
+ format: {
+ comments: false,
+ },
+ },
},
test: {
environment: 'jsdom',
@@ -34,4 +41,4 @@ export default defineConfig({
setupFiles: ['./src/__tests__/setup.js'],
include: ['src/**/*.{test,spec}.{js,jsx}'],
},
-});
+}));
diff --git a/packages/workers/package.json b/packages/workers/package.json
index 568e17e05..0ec5d1477 100644
--- a/packages/workers/package.json
+++ b/packages/workers/package.json
@@ -7,7 +7,7 @@
"license": "PolyForm-Noncommercial-1.0.0",
"scripts": {
"dev": "wrangler dev",
- "deploy": "wrangler deploy --env production",
+ "deploy": "pnpm --filter shared build && wrangler deploy --env production",
"tail": "wrangler tail",
"test": "pnpm db:generate:test && vitest run",
"test:watch": "vitest",
@@ -19,7 +19,7 @@
"db:migrate": "y | wrangler d1 migrations apply corates-db --local",
"db:migrate:prod": "wrangler d1 migrations apply corates-db --remote",
"db:setup:prod": "wrangler d1 execute corates-db-prod --remote --file=migrations/0001_init.sql",
- "db:reset:prod": "./scripts/reset-db-prod.sh",
+ "db:reset:prod": "node ./scripts/reset-db-prod.mjs",
"user:make-admin:local": "node ./scripts/make-admin-local.mjs",
"user:make-admin:prod": "node ./scripts/make-admin-prod.mjs",
"openapi:generate": "node ./scripts/generate-openapi.mjs"
diff --git a/packages/workers/scripts/reset-db-prod.mjs b/packages/workers/scripts/reset-db-prod.mjs
new file mode 100644
index 000000000..ce8530906
--- /dev/null
+++ b/packages/workers/scripts/reset-db-prod.mjs
@@ -0,0 +1,166 @@
+#!/usr/bin/env node
+/* global console, process */
+/**
+ * Reset production D1 database, clear R2 bucket, and redeploy workers
+ *
+ * Usage: node scripts/reset-db-prod.mjs
+ */
+
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..');
+
+const BUCKET_NAME = 'corates-pdfs-prod';
+const DB_NAME = 'corates-db-prod';
+
+function runCommand(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ cwd: ROOT,
+ ...options,
+ });
+
+ if (result.status !== 0 && !options.allowFailure) {
+ const stderr = (result.stderr || '').trim();
+ const stdout = (result.stdout || '').trim();
+ const message = stderr || stdout || `${command} exited with code ${result.status}`;
+ throw new Error(message);
+ }
+
+ return {
+ stdout: (result.stdout || '').trim(),
+ stderr: (result.stderr || '').trim(),
+ status: result.status,
+ };
+}
+
+function runWranglerD1Execute(command, options = {}) {
+ const args = [
+ 'wrangler',
+ 'd1',
+ 'execute',
+ DB_NAME,
+ '--remote',
+ '--env',
+ 'production',
+ '--yes',
+ '--command',
+ command,
+ ];
+
+ return runCommand('pnpm', args, options);
+}
+
+function runWranglerD1ExecuteFile(filePath) {
+ const args = [
+ 'wrangler',
+ 'd1',
+ 'execute',
+ DB_NAME,
+ '--remote',
+ '--env',
+ 'production',
+ '--yes',
+ '--file',
+ filePath,
+ ];
+
+ return runCommand('pnpm', args);
+}
+
+function deleteR2Bucket() {
+ const args = ['wrangler', 'r2', 'bucket', 'delete', BUCKET_NAME, '--env', 'production'];
+
+ return runCommand('pnpm', args, { allowFailure: true });
+}
+
+function createR2Bucket() {
+ const args = ['wrangler', 'r2', 'bucket', 'create', BUCKET_NAME, '--env', 'production'];
+
+ return runCommand('pnpm', args, { allowFailure: true });
+}
+
+function deployWorkers() {
+ const args = ['wrangler', 'deploy', '--env', 'production'];
+ return runCommand('pnpm', args);
+}
+
+async function main() {
+ console.log('==========================================');
+ console.log(' Resetting Production D1 Database & R2');
+ console.log('==========================================');
+ console.log('');
+
+ try {
+ // Step 1: Drop existing tables
+ console.log('');
+ console.log('Step 1: Dropping existing tables...');
+ const tables = [
+ 'mediaFiles',
+ 'project_members',
+ 'projects',
+ 'verification',
+ 'account',
+ 'session',
+ 'user',
+ ];
+
+ for (const table of tables) {
+ console.log(` Dropping table: ${table}`);
+ runWranglerD1Execute(`DROP TABLE IF EXISTS ${table};`);
+ }
+
+ // Step 2: Delete and recreate R2 bucket
+ // console.log('');
+ // console.log('Step 2: Clearing R2 bucket...');
+ // console.log(` Deleting bucket: ${BUCKET_NAME}`);
+ // const deleteResult = deleteR2Bucket();
+ // if (deleteResult.status !== 0) {
+ // console.log(` Note: ${deleteResult.stderr || 'Bucket may not exist or already deleted'}`);
+ // } else {
+ // console.log(' Bucket deleted');
+ // }
+
+ // console.log(` Creating bucket: ${BUCKET_NAME}`);
+ // const createResult = createR2Bucket();
+ // if (createResult.status !== 0) {
+ // console.log(
+ // ` Warning: Failed to create bucket: ${createResult.stderr || createResult.stdout}`,
+ // );
+ // throw new Error('Failed to recreate R2 bucket');
+ // } else {
+ // console.log(' Bucket created');
+ // }
+
+ // Step 3: Run migration
+ console.log('');
+ console.log('Step 3: Running migration...');
+ const migrationPath = join(ROOT, 'migrations', '0001_init.sql');
+ runWranglerD1ExecuteFile(migrationPath);
+ console.log(' Migration completed');
+
+ // Step 4: Deploy workers
+ console.log('');
+ console.log('Step 4: Deploying workers...');
+ deployWorkers();
+ console.log(' Workers deployed');
+
+ console.log('');
+ console.log('==========================================');
+ console.log(' Database reset, R2 cleared, and workers deployed!');
+ console.log('==========================================');
+ } catch (err) {
+ console.error('');
+ console.error('Error:', err.message || err);
+ process.exit(1);
+ }
+}
+
+main().catch(err => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/packages/workers/scripts/reset-db-prod.sh b/packages/workers/scripts/reset-db-prod.sh
deleted file mode 100755
index f8c854bdc..000000000
--- a/packages/workers/scripts/reset-db-prod.sh
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/bin/bash
-# Reset production D1 database and redeploy workers
-# Usage: ./scripts/reset-db-prod.sh
-
-set -e
-
-echo "=========================================="
-echo " Resetting Production D1 Database"
-echo "=========================================="
-echo ""
-
-cd "$(dirname "$0")/.."
-
-echo ""
-echo "Step 1: Dropping existing tables..."
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS mediaFiles;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS project_members;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS projects;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS verification;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS account;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS session;"
-npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS user;"
-
-echo ""
-echo "Step 2: Running migration..."
-npx wrangler d1 execute corates-db-prod --remote --yes --file=migrations/0001_init.sql
-
-echo ""
-echo "Step 3: Deploying workers..."
-npx wrangler deploy --env production
-
-echo ""
-echo "=========================================="
-echo " Database reset and workers deployed!"
-echo "=========================================="
diff --git a/packages/workers/src/middleware/cors.js b/packages/workers/src/middleware/cors.js
index ed7c8b176..d3453eb63 100644
--- a/packages/workers/src/middleware/cors.js
+++ b/packages/workers/src/middleware/cors.js
@@ -11,7 +11,7 @@ import { isOriginAllowed, STATIC_ORIGINS } from '../config/origins.js';
* @returns {Function} Hono middleware
*/
export function createCorsMiddleware(env) {
- return cors({
+ const corsHandler = cors({
origin: origin => {
if (isOriginAllowed(origin, env)) {
return origin;
@@ -22,6 +22,7 @@ export function createCorsMiddleware(env) {
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: [
'Content-Type',
+ 'Content-Length',
'Authorization',
'X-File-Name',
'X-Requested-With',
@@ -32,4 +33,15 @@ export function createCorsMiddleware(env) {
credentials: true,
maxAge: 86400, // Cache preflight for 24 hours
});
+
+ // Wrap CORS handler to skip WebSocket upgrade requests
+ return async (c, next) => {
+ // Skip CORS for WebSocket upgrade requests - they need special handling
+ // Use raw headers to match how Durable Objects check for upgrades
+ const upgradeHeader = c.req.raw.headers.get('Upgrade');
+ if (upgradeHeader === 'websocket') {
+ return next();
+ }
+ return corsHandler(c, next);
+ };
}
diff --git a/packages/workers/src/middleware/securityHeaders.js b/packages/workers/src/middleware/securityHeaders.js
index eae2b1168..bc576d8e5 100644
--- a/packages/workers/src/middleware/securityHeaders.js
+++ b/packages/workers/src/middleware/securityHeaders.js
@@ -11,6 +11,12 @@ export function securityHeaders() {
return async (c, next) => {
await next();
+ // Skip security headers for WebSocket upgrade responses (status 101)
+ // WebSocket upgrades require special handling and cannot have standard HTTP headers
+ if (c.res.status === 101) {
+ return;
+ }
+
// Enforce HTTPS for future requests (only meaningful over HTTPS)
try {
const url = new URL(c.req.url);
diff --git a/packages/workers/src/routes/pdfs.js b/packages/workers/src/routes/pdfs.js
index 722fb34f4..42f2d7eff 100644
--- a/packages/workers/src/routes/pdfs.js
+++ b/packages/workers/src/routes/pdfs.js
@@ -212,19 +212,11 @@ pdfRoutes.post('/', async c => {
return c.json(error, error.statusCode);
}
- // Check if file with same name already exists
- const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
- const existing = await c.env.PDF_BUCKET.head(key);
- if (existing) {
- const error = createDomainError(
- FILE_ERRORS.ALREADY_EXISTS,
- { fileName, key },
- `File "${fileName}" already exists. Rename or remove the existing copy.`,
- );
- return c.json(error, error.statusCode);
- }
-
// Store in R2
+ // Note: Frontend already checks for duplicate filenames before uploading,
+ // so we skip the R2 head() call to reduce latency. Race conditions are
+ // unlikely and would just result in an overwrite.
+ const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
await c.env.PDF_BUCKET.put(key, pdfData, {
httpMetadata: {
diff --git a/packages/workers/wrangler.jsonc b/packages/workers/wrangler.jsonc
index 54ce20590..67274dbe3 100644
--- a/packages/workers/wrangler.jsonc
+++ b/packages/workers/wrangler.jsonc
@@ -85,6 +85,16 @@
"zone_name": "corates.org"
}
],
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "logs": {
+ "enabled": true,
+ "head_sampling_rate": 1,
+ "persist": true,
+ "invocation_logs": true
+ }
+ },
"vars": {
"ENVIRONMENT": "production",
"AUTH_BASE_URL": "https://api.corates.org",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 91c893205..0c5905e8b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -276,9 +276,18 @@ importers:
puppeteer:
specifier: ^24.34.0
version: 24.34.0(typescript@5.9.3)
+ rollup-plugin-visualizer:
+ specifier: ^6.0.5
+ version: 6.0.5(rolldown@1.0.0-beta.51)(rollup@4.53.5)
+ source-map-explorer:
+ specifier: ^2.5.3
+ version: 2.5.3
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
+ terser:
+ specifier: ^5.44.1
+ version: 5.44.1
vite:
specifier: ^7.3.0
version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -4533,6 +4542,14 @@ packages:
engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 }
hasBin: true
+ btoa@1.2.1:
+ resolution:
+ {
+ integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==,
+ }
+ engines: { node: '>= 0.4.0' }
+ hasBin: true
+
buffer-crc32@0.2.13:
resolution:
{
@@ -4759,6 +4776,12 @@ packages:
}
engines: { node: '>=18' }
+ cliui@7.0.4:
+ resolution:
+ {
+ integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==,
+ }
+
cliui@8.0.1:
resolution:
{
@@ -4898,6 +4921,12 @@ packages:
}
engines: { node: '>= 0.6' }
+ convert-source-map@1.9.0:
+ resolution:
+ {
+ integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==,
+ }
+
convert-source-map@2.0.0:
resolution:
{
@@ -5618,6 +5647,14 @@ packages:
integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==,
}
+ ejs@3.1.10:
+ resolution:
+ {
+ integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==,
+ }
+ engines: { node: '>=0.10.0' }
+ hasBin: true
+
electron-to-chromium@1.5.267:
resolution:
{
@@ -6133,6 +6170,12 @@ packages:
integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==,
}
+ filelist@1.0.4:
+ resolution:
+ {
+ integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==,
+ }
+
fill-range@7.1.1:
resolution:
{
@@ -6221,6 +6264,12 @@ packages:
}
engines: { node: '>= 0.8' }
+ fs.realpath@1.0.0:
+ resolution:
+ {
+ integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==,
+ }
+
fsevents@2.3.3:
resolution:
{
@@ -6351,6 +6400,13 @@ packages:
}
hasBin: true
+ glob@7.2.3:
+ resolution:
+ {
+ integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==,
+ }
+ deprecated: Glob versions prior to v9 are no longer supported
+
globals@14.0.0:
resolution:
{
@@ -6385,6 +6441,13 @@ packages:
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
}
+ gzip-size@6.0.0:
+ resolution:
+ {
+ integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==,
+ }
+ engines: { node: '>=10' }
+
gzip-size@7.0.0:
resolution:
{
@@ -6599,6 +6662,13 @@ packages:
}
engines: { node: '>=12' }
+ inflight@1.0.6:
+ resolution:
+ {
+ integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==,
+ }
+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
inherits@2.0.4:
resolution:
{
@@ -6827,6 +6897,14 @@ packages:
integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==,
}
+ jake@10.9.4:
+ resolution:
+ {
+ integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==,
+ }
+ engines: { node: '>=10' }
+ hasBin: true
+
jiti@1.21.7:
resolution:
{
@@ -7422,6 +7500,12 @@ packages:
}
engines: { node: '>=16 || 14 >=14.17' }
+ minimist@1.2.8:
+ resolution:
+ {
+ integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==,
+ }
+
minipass@7.1.2:
resolution:
{
@@ -7442,6 +7526,13 @@ packages:
integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==,
}
+ mkdirp@0.5.6:
+ resolution:
+ {
+ integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==,
+ }
+ hasBin: true
+
mlly@1.8.0:
resolution:
{
@@ -7674,6 +7765,13 @@ packages:
integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==,
}
+ open@7.4.2:
+ resolution:
+ {
+ integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==,
+ }
+ engines: { node: '>=8' }
+
open@8.4.2:
resolution:
{
@@ -7762,6 +7860,13 @@ packages:
}
engines: { node: '>=8' }
+ path-is-absolute@1.0.1:
+ resolution:
+ {
+ integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==,
+ }
+ engines: { node: '>=0.10.0' }
+
path-key@3.1.1:
resolution:
{
@@ -8297,6 +8402,14 @@ packages:
}
engines: { iojs: '>=1.0.0', node: '>=0.10.0' }
+ rimraf@2.6.3:
+ resolution:
+ {
+ integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==,
+ }
+ deprecated: Rimraf versions prior to v4 are no longer supported
+ hasBin: true
+
robust-predicates@3.0.2:
resolution:
{
@@ -8670,6 +8783,14 @@ packages:
peerDependencies:
solid-js: ^1.7
+ source-map-explorer@2.5.3:
+ resolution:
+ {
+ integrity: sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==,
+ }
+ engines: { node: '>=12' }
+ hasBin: true
+
source-map-js@1.2.1:
resolution:
{
@@ -8938,6 +9059,13 @@ packages:
}
engines: { node: '>=18' }
+ temp@0.9.4:
+ resolution:
+ {
+ integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==,
+ }
+ engines: { node: '>=6.0.0' }
+
terracotta@1.0.6:
resolution:
{
@@ -9941,6 +10069,13 @@ packages:
engines: { node: '>= 14.6' }
hasBin: true
+ yargs-parser@20.2.9:
+ resolution:
+ {
+ integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==,
+ }
+ engines: { node: '>=10' }
+
yargs-parser@21.1.1:
resolution:
{
@@ -9948,6 +10083,13 @@ packages:
}
engines: { node: '>=12' }
+ yargs@16.2.0:
+ resolution:
+ {
+ integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==,
+ }
+ engines: { node: '>=10' }
+
yargs@17.7.2:
resolution:
{
@@ -12543,6 +12685,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ btoa@1.2.1: {}
+
buffer-crc32@0.2.13: {}
buffer-crc32@1.0.0: {}
@@ -12658,6 +12802,12 @@ snapshots:
is-wsl: 3.1.0
is64bit: 2.0.0
+ cliui@7.0.4:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -12727,6 +12877,8 @@ snapshots:
content-type@1.0.5: {}
+ convert-source-map@1.9.0: {}
+
convert-source-map@2.0.0: {}
cookie-es@1.2.2: {}
@@ -13065,6 +13217,10 @@ snapshots:
ee-first@1.1.1: {}
+ ejs@3.1.10:
+ dependencies:
+ jake: 10.9.4
+
electron-to-chromium@1.5.267: {}
emoji-regex-xs@1.0.0: {}
@@ -13534,6 +13690,10 @@ snapshots:
file-uri-to-path@1.0.0: {}
+ filelist@1.0.4:
+ dependencies:
+ minimatch: 5.1.6
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -13584,6 +13744,8 @@ snapshots:
fresh@2.0.0: {}
+ fs.realpath@1.0.0: {}
+
fsevents@2.3.3:
optional: true
@@ -13674,6 +13836,15 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
+ glob@7.2.3:
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+
globals@14.0.0: {}
globals@16.5.0: {}
@@ -13691,6 +13862,10 @@ snapshots:
graceful-fs@4.2.11: {}
+ gzip-size@6.0.0:
+ dependencies:
+ duplexer: 0.1.2
+
gzip-size@7.0.0:
dependencies:
duplexer: 0.1.2
@@ -13826,6 +14001,11 @@ snapshots:
indent-string@5.0.0: {}
+ inflight@1.0.6:
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
@@ -13928,6 +14108,12 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
+ jake@10.9.4:
+ dependencies:
+ async: 3.2.6
+ filelist: 1.0.4
+ picocolors: 1.1.1
+
jiti@1.21.7: {}
jiti@2.6.1: {}
@@ -14283,6 +14469,8 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
+ minimist@1.2.8: {}
+
minipass@7.1.2: {}
minizlib@3.1.0:
@@ -14291,6 +14479,10 @@ snapshots:
mitt@3.0.1: {}
+ mkdirp@0.5.6:
+ dependencies:
+ minimist: 1.2.8
+
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -14488,6 +14680,11 @@ snapshots:
regex: 5.1.1
regex-recursion: 5.1.1
+ open@7.4.2:
+ dependencies:
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
+
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
@@ -14554,6 +14751,8 @@ snapshots:
path-exists@4.0.0: {}
+ path-is-absolute@1.0.1: {}
+
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -14831,6 +15030,10 @@ snapshots:
reusify@1.1.0: {}
+ rimraf@2.6.3:
+ dependencies:
+ glob: 7.2.3
+
robust-predicates@3.0.2: {}
rolldown@1.0.0-beta.51:
@@ -15145,6 +15348,21 @@ snapshots:
dependencies:
solid-js: 1.9.10
+ source-map-explorer@2.5.3:
+ dependencies:
+ btoa: 1.2.1
+ chalk: 4.1.2
+ convert-source-map: 1.9.0
+ ejs: 3.1.10
+ escape-html: 1.0.3
+ glob: 7.2.3
+ gzip-size: 6.0.0
+ lodash: 4.17.21
+ open: 7.4.2
+ source-map: 0.7.6
+ temp: 0.9.4
+ yargs: 16.2.0
+
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -15295,6 +15513,11 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
+ temp@0.9.4:
+ dependencies:
+ mkdirp: 0.5.6
+ rimraf: 2.6.3
+
terracotta@1.0.6(solid-js@1.9.10):
dependencies:
solid-js: 1.9.10
@@ -16055,8 +16278,20 @@ snapshots:
yaml@2.8.2: {}
+ yargs-parser@20.2.9: {}
+
yargs-parser@21.1.1: {}
+ yargs@16.2.0:
+ dependencies:
+ cliui: 7.0.4
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 20.2.9
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/scripts/loc-report.mjs b/scripts/loc-report.mjs
new file mode 100644
index 000000000..1647b51ee
--- /dev/null
+++ b/scripts/loc-report.mjs
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/* global console, process */
+/**
+ * LOC (Lines of Code) report script
+ *
+ * Usage:
+ * node scripts/loc-report.mjs # total + per package in packages/
+ * node scripts/loc-report.mjs packages # only packages/* breakdown
+ * node scripts/loc-report.mjs web # only the "web" top-level dir
+ */
+
+import { spawnSync } from 'node:child_process';
+import { writeFileSync, unlinkSync, mkdtempSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..');
+
+function checkCommand(command) {
+ const result = spawnSync('which', [command], { encoding: 'utf8' });
+ if (result.status !== 0) {
+ if (command === 'cloc') {
+ console.error(`cloc not found in PATH. Install it: https://github.com/AlDanial/cloc`);
+ } else {
+ console.error(`${command} not found in PATH`);
+ }
+ process.exit(2);
+ }
+}
+
+function runCommand(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ cwd: ROOT,
+ ...options,
+ });
+
+ if (result.status !== 0 && !options.allowFailure) {
+ const stderr = (result.stderr || '').trim();
+ const stdout = (result.stdout || '').trim();
+ const message = stderr || stdout || `${command} exited with code ${result.status}`;
+ throw new Error(message);
+ }
+
+ return {
+ stdout: (result.stdout || '').trim(),
+ stderr: (result.stderr || '').trim(),
+ status: result.status,
+ };
+}
+
+function getTrackedFiles() {
+ // Get tracked files, excluding lock files
+ const result = runCommand('git', ['ls-files', '-z']);
+ const files = result.stdout
+ .split('\0')
+ .filter(file => {
+ // Exclude lock files
+ return !/^(.+\/)?(pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/.test(file);
+ })
+ .filter(Boolean);
+
+ return files;
+}
+
+function printHeader(title) {
+ console.log('');
+ console.log('===========================================');
+ console.log(title);
+ console.log('===========================================');
+}
+
+function runCloc(fileList) {
+ if (fileList.length === 0) {
+ return;
+ }
+
+ // Create temporary file with file list
+ const tmpDir = mkdtempSync(join(tmpdir(), 'loc-report-'));
+ const tmpFile = join(tmpDir, 'filelist.txt');
+ writeFileSync(tmpFile, fileList.join('\n'));
+
+ try {
+ const result = runCommand('cloc', ['--list-file=' + tmpFile, '--quiet'], {
+ allowFailure: true,
+ });
+ if (result.status === 0) {
+ console.log(result.stdout);
+ }
+ } finally {
+ try {
+ unlinkSync(tmpFile);
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
+
+function runSubset(label, fileList) {
+ if (fileList.length === 0) {
+ return;
+ }
+ console.log(`\n--- ${label} (${fileList.length} files) ---`);
+ runCloc(fileList);
+}
+
+function main() {
+ // Check required commands
+ checkCommand('git');
+ checkCommand('cloc');
+
+ const args = process.argv.slice(2);
+ const filter = args[0]; // Optional filter: 'packages' or specific dir
+
+ // Get tracked files
+ let trackedFiles = getTrackedFiles();
+
+ if (trackedFiles.length === 0) {
+ console.error('No tracked files found.');
+ process.exit(1);
+ }
+
+ // Apply filter if specified
+ if (filter) {
+ if (filter === 'packages') {
+ trackedFiles = trackedFiles.filter(file => file.startsWith('packages/'));
+ } else {
+ trackedFiles = trackedFiles.filter(file => file.startsWith(`${filter}/`));
+ }
+ }
+
+ // Total (tracked files)
+ printHeader('Total (Git-tracked files)');
+ runCloc(trackedFiles);
+
+ // Per-package breakdown (packages/*)
+ const packageFiles = trackedFiles.filter(file => file.startsWith('packages/'));
+ if (packageFiles.length > 0) {
+ printHeader('Per package (packages/*)');
+
+ // Group files by package
+ const packages = new Map();
+ for (const file of packageFiles) {
+ const parts = file.split('/');
+ if (parts.length >= 2 && parts[0] === 'packages') {
+ const pkg = parts[1];
+ if (!packages.has(pkg)) {
+ packages.set(pkg, []);
+ }
+ packages.get(pkg).push(file);
+ }
+ }
+
+ // Sort packages and run cloc for each
+ const sortedPackages = Array.from(packages.keys()).sort();
+ for (const pkg of sortedPackages) {
+ const pkgFiles = packages.get(pkg);
+ runSubset(`packages/${pkg}`, pkgFiles);
+ }
+ } else {
+ console.log('');
+ console.log('No packages/ directory detected or no tracked files under packages/.');
+ }
+}
+
+try {
+ main();
+} catch (err) {
+ console.error('Error:', err.message || err);
+ process.exit(1);
+}
diff --git a/scripts/loc-report.sh b/scripts/loc-report.sh
deleted file mode 100644
index ac33a197e..000000000
--- a/scripts/loc-report.sh
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# LOC report script
-# Usage:
-# ./scripts/loc-report.sh # total + per top-level + per package in packages/
-# ./scripts/loc-report.sh packages # only packages/* breakdown
-# ./scripts/loc-report.sh web # only the "web" top-level dir
-
-command -v git >/dev/null 2>&1 || { echo "git not found in PATH" >&2; exit 2; }
-command -v cloc >/dev/null 2>&1 || { echo "cloc not found in PATH. Install it: https://github.com/AlDanial/cloc" >&2; exit 2; }
-
-TMP_TRACKED=$(mktemp)
-trap 'rm -f "$TMP_TRACKED"' EXIT
-
-# Gather tracked files (one per line), excluding common lock files
-# (pnpm-lock.yaml, package-lock.json, yarn.lock) which are tracked but not source
-git ls-files -z | tr '\0' '\n' | grep -vE '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock)$' > "$TMP_TRACKED"
-
-if [ ! -s "$TMP_TRACKED" ]; then
- echo "No tracked files found." >&2
- exit 1
-fi
-
-print_header() {
- echo
- echo "==========================================="
- echo "$1"
- echo "==========================================="
-}
-
-# Total (tracked files)
-print_header "Total (Git-tracked files)"
-cloc --list-file="$TMP_TRACKED" --quiet || true
-
-# Helper to run cloc on a subset list
-run_subset() {
- local label="$1" filelist="$2"
- if [ ! -s "$filelist" ]; then
- return
- fi
- printf "\n--- %s (%s files) ---\n" "$label" "$(wc -l < "$filelist")"
- cloc --list-file="$filelist" --quiet || true
-}
-
-# Only show per-package breakdown (packages/*) — avoid per-top-level dirs like docs
-if grep -q '^packages/' "$TMP_TRACKED"; then
- print_header "Per package (packages/*)"
- packages=$(awk -F/ '$1=="packages"{print $2}' "$TMP_TRACKED" | sort -u)
- for p in $packages; do
- subset=$(mktemp)
- grep -E "^packages/$p/" "$TMP_TRACKED" > "$subset"
- if [ -s "$subset" ]; then
- run_subset "packages/$p" "$subset"
- fi
- rm -f "$subset"
- done
-else
- echo
- echo "No packages/ directory detected or no tracked files under packages/."
-fi
-
-exit 0