Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ jobs:
id: docs
run: pnpm docs:build

- name: 9. Check README
- name: 9. Check bundled MCP server
id: mcp
run: pnpm mcp:check

- name: 10. Check README
id: readme
run: pnpm readme:check

Expand All @@ -67,5 +71,6 @@ jobs:
echo "| Tests | ${{ steps.test.outcome }} |"
echo "| Package build | ${{ steps.build.outcome }} |"
echo "| Docs build | ${{ steps.docs.outcome }} |"
echo "| MCP server check | ${{ steps.mcp.outcome }} |"
echo "| README check | ${{ steps.readme.outcome }} |"
} >> "$GITHUB_STEP_SUMMARY"
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ jobs:
id: docs
run: pnpm docs:build

- name: Check bundled MCP server
id: mcp
run: pnpm mcp:check

- name: README check
id: readme
run: pnpm readme:check
Expand Down Expand Up @@ -196,6 +200,7 @@ jobs:
echo "| Tests | ${{ steps.test.outcome }} |"
echo "| Package build | ${{ steps.build.outcome }} |"
echo "| Docs build | ${{ steps.docs.outcome }} |"
echo "| MCP server check | ${{ steps.mcp.outcome }} |"
echo "| README check | ${{ steps.readme.outcome }} |"
echo "| Size report | ${{ steps.size.outcome }} |"
echo "| Pack dry run | ${{ steps.pack.outcome }} |"
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ The default import stays small. Add the other pieces only when that screen needs
| 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 8.62 kB | 3.02 kB | 2.78 kB |
| 🧩 Duration | `@crup/react-timer-hook/duration` | `days`, `hours`, `minutes`, `seconds`, `milliseconds` | 318 B | 224 B | 192 B |
| 🔎 Diagnostics | `@crup/react-timer-hook/diagnostics` | Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B |
| 🤖 MCP docs server | `react-timer-hook-mcp` | Optional local docs context for MCP clients and coding agents | 3.80 kB | 1.63 kB | 1.40 kB |

CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.

Expand All @@ -269,17 +270,34 @@ Agents and docs-aware IDEs can use:

Optional local MCP docs server:

Use `npx` if the package is not installed in the current project:

```json
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "npx",
"args": ["-y", "@crup/react-timer-hook@latest"]
}
}
}
```

If the package is installed locally, npm also creates a bin shim in `node_modules/.bin`:

```json
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "node",
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
"command": "./node_modules/.bin/react-timer-hook-mcp",
"args": []
}
}
}
```

The same bundled and minified server is available at `node_modules/@crup/react-timer-hook/dist/mcp/server.js`.

It exposes:

```txt
Expand Down
1 change: 1 addition & 0 deletions commitlint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
'deps',
'docs',
'group',
'mcp',
'release',
'schedules',
'state',
Expand Down
18 changes: 13 additions & 5 deletions docs-site/docs/ai.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ pnpm ai:context

## MCP server

Run the local docs MCP server:
Run the published docs MCP server:

```sh
node /absolute/path/to/react-timer-hook/mcp/server.mjs
npx -y @crup/react-timer-hook@latest
```

MCP client config:
Expand All @@ -41,13 +41,21 @@ MCP client config:
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "node",
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
"command": "npx",
"args": ["-y", "@crup/react-timer-hook@latest"]
}
}
}
```

If the package is already installed locally, you can also run:

```sh
./node_modules/.bin/react-timer-hook-mcp
```

The bin shim points at the same bundled file: `node_modules/@crup/react-timer-hook/dist/mcp/server.js`.

It exposes:

```txt
Expand All @@ -62,4 +70,4 @@ Verify locally:
printf '{"jsonrpc":"2.0","id":1,"method":"resources/list"}\n' | pnpm mcp:docs
```

The npm package stays runtime-focused. AI context and MCP helpers live in the source repository for contributors and coding agents.
The MCP server is bundled and minified into the npm package. It only exposes documentation resources; the React runtime remains separate from the MCP helper.
6 changes: 4 additions & 2 deletions docs-site/static/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@ Local docs MCP server:
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "node",
"args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
"command": "npx",
"args": ["-y", "@crup/react-timer-hook@latest"]
}
}
}
```

The package bundles the MCP server at node_modules/@crup/react-timer-hook/dist/mcp/server.js and exposes it through the react-timer-hook-mcp bin.

## Boundaries

Use the hook for timer lifecycle, elapsed time, schedules, and controls. Keep UI display and data fetching in your app.
58 changes: 55 additions & 3 deletions mcp/server.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import { createInterface } from 'node:readline';
import { readFileSync } from 'node:fs';

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const pkg = readPackage();
const apiText = `# @crup/react-timer-hook

A lightweight React hooks library for building timers, stopwatches, and real-time clocks with minimal boilerplate.

Docs: https://crup.github.io/react-timer-hook/
Package: @crup/react-timer-hook
Install: npm install @crup/react-timer-hook@latest
Runtime: Node 18+ and React 18+
Repository: https://github.com/crup/react-timer-hook

Public exports:
- @crup/react-timer-hook: useTimer(options) for one timer lifecycle.
- @crup/react-timer-hook/group: useTimerGroup(options) for many keyed independent lifecycles with one shared scheduler.
- @crup/react-timer-hook/schedules: useScheduledTimer(options) for schedule-enabled timers with timing context.
- @crup/react-timer-hook/duration: durationParts(milliseconds) for duration display helper values.
- @crup/react-timer-hook/diagnostics: consoleTimerDiagnostics(options) for optional event logging.

Core rules:
- Use timer.now for wall-clock deadlines and clocks.
- Use timer.elapsedMilliseconds for active elapsed duration.
- Use endWhen(snapshot) to end a lifecycle.
- Use onError(error, snapshot, controls) when onEnd can throw or reject.
- Use cancel(reason) for terminal early stops.
- Keep formatting, timezone, retries, and business rules in userland.

Schedules:
- Use useScheduledTimer() from @crup/react-timer-hook/schedules.
- Schedules are opt-in and default to overlap: "skip".
- Schedule callbacks receive context with scheduledAt, firedAt, nextRunAt, overdueCount, and effectiveEveryMs.
- Schedule callbacks can define onError(error, snapshot, controls, context); otherwise timer or item onError is used.

Recipes:
- Wall clock: new Date(timer.now).
- Stopwatch: render timer.elapsedMilliseconds.
- Absolute countdown: Math.max(0, expiresAt - timer.now).
- Pausable countdown: durationMs - timer.elapsedMilliseconds.
- OTP resend: disable the resend button until elapsedMilliseconds reaches the cooldown.
- Polling: use schedules with overlap: "skip".
- Many independent timers: use useTimerGroup().
`;

const resources = {
'react-timer-hook://package': {
Expand All @@ -12,7 +52,7 @@ const resources = {
name: pkg.name,
version: pkg.version,
docs: 'https://crup.github.io/react-timer-hook/',
install: `npm install ${pkg.name}@alpha`,
install: `npm install ${pkg.name}@latest`,
},
null,
2,
Expand All @@ -21,7 +61,7 @@ const resources = {
'react-timer-hook://api': {
name: 'API',
mimeType: 'text/markdown',
text: readFileSync(new URL('../docs-site/static/llms-full.txt', import.meta.url), 'utf8'),
text: apiText,
},
'react-timer-hook://recipes': {
name: 'Recipes',
Expand Down Expand Up @@ -101,3 +141,15 @@ function respond(id, result) {
function respondError(id, code, message) {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
}

function readPackage() {
for (const path of ['../../package.json', '../package.json']) {
try {
return JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8'));
} catch {
// Try the next path. The bundled file runs from dist/mcp, while the source file runs from mcp.
}
}

return { name: '@crup/react-timer-hook', version: '0.0.0' };
}
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"react-timer-hook-mcp": "./dist/mcp/server.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand All @@ -38,6 +41,9 @@
"types": "./dist/diagnostics.d.ts",
"import": "./dist/diagnostics.js",
"require": "./dist/diagnostics.cjs"
},
"./mcp/server": {
"import": "./dist/mcp/server.js"
}
},
"files": [
Expand All @@ -60,6 +66,7 @@
"docs:dev": "NO_UPDATE_NOTIFIER=1 docusaurus start docs-site",
"docs:preview": "NO_UPDATE_NOTIFIER=1 docusaurus serve docs-site/build",
"mcp:docs": "node mcp/server.mjs",
"mcp:check": "node scripts/check-mcp-server.mjs",
"prepare": "husky",
"readme:check": "node scripts/check-readme.mjs",
"release": "changeset publish",
Expand Down
2 changes: 1 addition & 1 deletion scripts/ai-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const context = {
docs: 'https://crup.github.io/react-timer-hook/',
repository: 'https://github.com/crup/react-timer-hook',
install: {
alpha: `npm install ${pkg.name}@alpha`,
latest: `npm install ${pkg.name}@latest`,
},
runtime: {
node: '>=18.0.0',
Expand Down
72 changes: 72 additions & 0 deletions scripts/check-mcp-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';

const serverPath = 'dist/mcp/server.js';

if (!existsSync(serverPath)) {
console.error(`${serverPath} is missing. Run pnpm build first.`);
process.exit(1);
}

const child = spawn(process.execPath, [serverPath], {
stdio: ['pipe', 'pipe', 'pipe'],
});

let stdout = '';
let stderr = '';

child.stdout.on('data', chunk => {
stdout += chunk;
});

child.stderr.on('data', chunk => {
stderr += chunk;
});

child.stdin.end(
[
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'resources/list', params: {} }),
JSON.stringify({
jsonrpc: '2.0',
id: 3,
method: 'resources/read',
params: { uri: 'react-timer-hook://api' },
}),
'',
].join('\n'),
);

const timeout = setTimeout(() => {
child.kill('SIGTERM');
console.error('MCP server check timed out.');
process.exit(1);
}, 2000);

child.on('close', code => {
clearTimeout(timeout);

if (code !== 0) {
console.error(stderr || `MCP server exited with code ${code}.`);
process.exit(1);
}

const responses = stdout
.trim()
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line));

const list = responses.find(response => response.id === 2)?.result?.resources ?? [];
const api = responses.find(response => response.id === 3)?.result?.contents?.[0]?.text ?? '';

if (list.length !== 3) {
console.error(`Expected 3 MCP resources, received ${list.length}.`);
process.exit(1);
}

if (!api.includes('@crup/react-timer-hook') || !api.includes('useTimerGroup')) {
console.error('MCP API resource is missing expected package context.');
process.exit(1);
}
});
1 change: 1 addition & 0 deletions scripts/check-readme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const required = [
'durationParts',
'https://crup.github.io/react-timer-hook/',
'@crup/react-timer-hook@latest',
'react-timer-hook-mcp',
'Bundle size',
'AI-friendly',
];
Expand Down
1 change: 1 addition & 0 deletions scripts/size-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const entries = [
['schedules add-on', 'dist/schedules.js'],
['duration helper', 'dist/duration.js'],
['diagnostics helper', 'dist/diagnostics.js'],
['MCP docs server', 'dist/mcp/server.js'],
['core CJS', 'dist/index.cjs'],
['timer group CJS', 'dist/group.cjs'],
['schedules CJS', 'dist/schedules.cjs'],
Expand Down
Loading
Loading