@orkify/cli 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/README.md +1701 -0
- package/bin/orkify +3 -0
- package/boot/systemd/orkify@.service +30 -0
- package/dist/agent-name.d.ts +4 -0
- package/dist/agent-name.js +42 -0
- package/dist/alerts/AlertEvaluator.d.ts +14 -0
- package/dist/alerts/AlertEvaluator.js +135 -0
- package/dist/cli/commands/autostart.d.ts +3 -0
- package/dist/cli/commands/autostart.js +11 -0
- package/dist/cli/commands/crash-test.d.ts +3 -0
- package/dist/cli/commands/crash-test.js +17 -0
- package/dist/cli/commands/daemon-reload.d.ts +3 -0
- package/dist/cli/commands/daemon-reload.js +72 -0
- package/dist/cli/commands/delete.d.ts +3 -0
- package/dist/cli/commands/delete.js +37 -0
- package/dist/cli/commands/deploy.d.ts +6 -0
- package/dist/cli/commands/deploy.js +266 -0
- package/dist/cli/commands/down.d.ts +3 -0
- package/dist/cli/commands/down.js +36 -0
- package/dist/cli/commands/flush.d.ts +3 -0
- package/dist/cli/commands/flush.js +28 -0
- package/dist/cli/commands/kill.d.ts +3 -0
- package/dist/cli/commands/kill.js +35 -0
- package/dist/cli/commands/list.d.ts +14 -0
- package/dist/cli/commands/list.js +361 -0
- package/dist/cli/commands/logs.d.ts +3 -0
- package/dist/cli/commands/logs.js +107 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.js +151 -0
- package/dist/cli/commands/reload.d.ts +3 -0
- package/dist/cli/commands/reload.js +54 -0
- package/dist/cli/commands/restart.d.ts +3 -0
- package/dist/cli/commands/restart.js +43 -0
- package/dist/cli/commands/restore.d.ts +3 -0
- package/dist/cli/commands/restore.js +88 -0
- package/dist/cli/commands/run.d.ts +8 -0
- package/dist/cli/commands/run.js +212 -0
- package/dist/cli/commands/snap.d.ts +3 -0
- package/dist/cli/commands/snap.js +30 -0
- package/dist/cli/commands/up.d.ts +3 -0
- package/dist/cli/commands/up.js +125 -0
- package/dist/cli/crash-recovery.d.ts +2 -0
- package/dist/cli/crash-recovery.js +67 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +46 -0
- package/dist/cli/parse.d.ts +28 -0
- package/dist/cli/parse.js +97 -0
- package/dist/cluster/ClusterWrapper.d.ts +18 -0
- package/dist/cluster/ClusterWrapper.js +602 -0
- package/dist/config/ConfigStore.d.ts +11 -0
- package/dist/config/ConfigStore.js +21 -0
- package/dist/config/schema.d.ts +103 -0
- package/dist/config/schema.js +49 -0
- package/dist/constants.d.ts +83 -0
- package/dist/constants.js +289 -0
- package/dist/cron/CronScheduler.d.ts +25 -0
- package/dist/cron/CronScheduler.js +149 -0
- package/dist/daemon/GracefulManager.d.ts +8 -0
- package/dist/daemon/GracefulManager.js +29 -0
- package/dist/daemon/ManagedProcess.d.ts +71 -0
- package/dist/daemon/ManagedProcess.js +1020 -0
- package/dist/daemon/Orchestrator.d.ts +51 -0
- package/dist/daemon/Orchestrator.js +416 -0
- package/dist/daemon/RotatingWriter.d.ts +27 -0
- package/dist/daemon/RotatingWriter.js +264 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +106 -0
- package/dist/daemon/startDaemon.d.ts +30 -0
- package/dist/daemon/startDaemon.js +693 -0
- package/dist/deploy/CommandPoller.d.ts +13 -0
- package/dist/deploy/CommandPoller.js +53 -0
- package/dist/deploy/DeployExecutor.d.ts +33 -0
- package/dist/deploy/DeployExecutor.js +340 -0
- package/dist/deploy/config.d.ts +20 -0
- package/dist/deploy/config.js +161 -0
- package/dist/deploy/env.d.ts +2 -0
- package/dist/deploy/env.js +17 -0
- package/dist/deploy/tarball.d.ts +32 -0
- package/dist/deploy/tarball.js +243 -0
- package/dist/detect/framework.d.ts +2 -0
- package/dist/detect/framework.js +24 -0
- package/dist/ipc/DaemonClient.d.ts +31 -0
- package/dist/ipc/DaemonClient.js +248 -0
- package/dist/ipc/DaemonServer.d.ts +28 -0
- package/dist/ipc/DaemonServer.js +166 -0
- package/dist/ipc/MultiUserClient.d.ts +27 -0
- package/dist/ipc/MultiUserClient.js +203 -0
- package/dist/ipc/protocol.d.ts +7 -0
- package/dist/ipc/protocol.js +53 -0
- package/dist/ipc/restoreDaemon.d.ts +8 -0
- package/dist/ipc/restoreDaemon.js +19 -0
- package/dist/machine-id.d.ts +11 -0
- package/dist/machine-id.js +51 -0
- package/dist/mcp/auth.d.ts +118 -0
- package/dist/mcp/auth.js +245 -0
- package/dist/mcp/http.d.ts +20 -0
- package/dist/mcp/http.js +229 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +8 -0
- package/dist/mcp/server.d.ts +37 -0
- package/dist/mcp/server.js +413 -0
- package/dist/probe/compute-fingerprint.d.ts +27 -0
- package/dist/probe/compute-fingerprint.js +65 -0
- package/dist/probe/parse-frames.d.ts +21 -0
- package/dist/probe/parse-frames.js +57 -0
- package/dist/probe/resolve-sourcemaps.d.ts +25 -0
- package/dist/probe/resolve-sourcemaps.js +281 -0
- package/dist/state/StateStore.d.ts +11 -0
- package/dist/state/StateStore.js +78 -0
- package/dist/telemetry/TelemetryReporter.d.ts +49 -0
- package/dist/telemetry/TelemetryReporter.js +451 -0
- package/dist/types/index.d.ts +373 -0
- package/dist/types/index.js +2 -0
- package/package.json +148 -0
- package/packages/cache/README.md +114 -0
- package/packages/cache/dist/CacheClient.d.ts +26 -0
- package/packages/cache/dist/CacheClient.d.ts.map +1 -0
- package/packages/cache/dist/CacheClient.js +174 -0
- package/packages/cache/dist/CacheClient.js.map +1 -0
- package/packages/cache/dist/CacheFileStore.d.ts +45 -0
- package/packages/cache/dist/CacheFileStore.d.ts.map +1 -0
- package/packages/cache/dist/CacheFileStore.js +446 -0
- package/packages/cache/dist/CacheFileStore.js.map +1 -0
- package/packages/cache/dist/CachePersistence.d.ts +9 -0
- package/packages/cache/dist/CachePersistence.d.ts.map +1 -0
- package/packages/cache/dist/CachePersistence.js +67 -0
- package/packages/cache/dist/CachePersistence.js.map +1 -0
- package/packages/cache/dist/CachePrimary.d.ts +25 -0
- package/packages/cache/dist/CachePrimary.d.ts.map +1 -0
- package/packages/cache/dist/CachePrimary.js +155 -0
- package/packages/cache/dist/CachePrimary.js.map +1 -0
- package/packages/cache/dist/CacheStore.d.ts +50 -0
- package/packages/cache/dist/CacheStore.d.ts.map +1 -0
- package/packages/cache/dist/CacheStore.js +271 -0
- package/packages/cache/dist/CacheStore.js.map +1 -0
- package/packages/cache/dist/constants.d.ts +6 -0
- package/packages/cache/dist/constants.d.ts.map +1 -0
- package/packages/cache/dist/constants.js +9 -0
- package/packages/cache/dist/constants.js.map +1 -0
- package/packages/cache/dist/index.d.ts +16 -0
- package/packages/cache/dist/index.d.ts.map +1 -0
- package/packages/cache/dist/index.js +86 -0
- package/packages/cache/dist/index.js.map +1 -0
- package/packages/cache/dist/serialize.d.ts +9 -0
- package/packages/cache/dist/serialize.d.ts.map +1 -0
- package/packages/cache/dist/serialize.js +40 -0
- package/packages/cache/dist/serialize.js.map +1 -0
- package/packages/cache/dist/types.d.ts +123 -0
- package/packages/cache/dist/types.d.ts.map +1 -0
- package/packages/cache/dist/types.js +2 -0
- package/packages/cache/dist/types.js.map +1 -0
- package/packages/cache/package.json +27 -0
- package/packages/cache/src/CacheClient.ts +227 -0
- package/packages/cache/src/CacheFileStore.ts +528 -0
- package/packages/cache/src/CachePersistence.ts +89 -0
- package/packages/cache/src/CachePrimary.ts +172 -0
- package/packages/cache/src/CacheStore.ts +308 -0
- package/packages/cache/src/constants.ts +10 -0
- package/packages/cache/src/index.ts +100 -0
- package/packages/cache/src/serialize.ts +49 -0
- package/packages/cache/src/types.ts +156 -0
- package/packages/cache/tsconfig.json +18 -0
- package/packages/cache/tsconfig.tsbuildinfo +1 -0
- package/packages/next/README.md +166 -0
- package/packages/next/dist/error-capture.d.ts +34 -0
- package/packages/next/dist/error-capture.d.ts.map +1 -0
- package/packages/next/dist/error-capture.js +130 -0
- package/packages/next/dist/error-capture.js.map +1 -0
- package/packages/next/dist/error-handler.d.ts +10 -0
- package/packages/next/dist/error-handler.d.ts.map +1 -0
- package/packages/next/dist/error-handler.js +186 -0
- package/packages/next/dist/error-handler.js.map +1 -0
- package/packages/next/dist/isr-cache.d.ts +9 -0
- package/packages/next/dist/isr-cache.d.ts.map +1 -0
- package/packages/next/dist/isr-cache.js +86 -0
- package/packages/next/dist/isr-cache.js.map +1 -0
- package/packages/next/dist/stream.d.ts +5 -0
- package/packages/next/dist/stream.d.ts.map +1 -0
- package/packages/next/dist/stream.js +22 -0
- package/packages/next/dist/stream.js.map +1 -0
- package/packages/next/dist/types.d.ts +33 -0
- package/packages/next/dist/types.d.ts.map +1 -0
- package/packages/next/dist/types.js +6 -0
- package/packages/next/dist/types.js.map +1 -0
- package/packages/next/dist/use-cache.d.ts +4 -0
- package/packages/next/dist/use-cache.d.ts.map +1 -0
- package/packages/next/dist/use-cache.js +86 -0
- package/packages/next/dist/use-cache.js.map +1 -0
- package/packages/next/dist/utils.d.ts +32 -0
- package/packages/next/dist/utils.d.ts.map +1 -0
- package/packages/next/dist/utils.js +88 -0
- package/packages/next/dist/utils.js.map +1 -0
- package/packages/next/package.json +52 -0
- package/packages/next/src/error-capture.ts +177 -0
- package/packages/next/src/error-handler.ts +221 -0
- package/packages/next/src/isr-cache.ts +100 -0
- package/packages/next/src/stream.ts +23 -0
- package/packages/next/src/types.ts +33 -0
- package/packages/next/src/use-cache.ts +99 -0
- package/packages/next/src/utils.ts +102 -0
- package/packages/next/tsconfig.json +19 -0
- package/packages/next/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orkify/next",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Next.js integration for orkify — cache handlers and browser error tracking",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./use-cache": {
|
|
9
|
+
"types": "./src/use-cache.ts",
|
|
10
|
+
"import": "./dist/use-cache.js",
|
|
11
|
+
"default": "./dist/use-cache.js"
|
|
12
|
+
},
|
|
13
|
+
"./isr-cache": {
|
|
14
|
+
"types": "./src/isr-cache.ts",
|
|
15
|
+
"import": "./dist/isr-cache.js",
|
|
16
|
+
"default": "./dist/isr-cache.js"
|
|
17
|
+
},
|
|
18
|
+
"./error-capture": {
|
|
19
|
+
"types": "./src/error-capture.ts",
|
|
20
|
+
"import": "./dist/error-capture.js",
|
|
21
|
+
"default": "./dist/error-capture.js"
|
|
22
|
+
},
|
|
23
|
+
"./error-handler": {
|
|
24
|
+
"types": "./src/error-handler.ts",
|
|
25
|
+
"import": "./dist/error-handler.js",
|
|
26
|
+
"default": "./dist/error-handler.js"
|
|
27
|
+
},
|
|
28
|
+
"./utils": {
|
|
29
|
+
"types": "./src/utils.ts",
|
|
30
|
+
"import": "./dist/utils.js",
|
|
31
|
+
"default": "./dist/utils.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@orkify/cache": "file:../cache"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": ">=18",
|
|
42
|
+
"zod": ">=3"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"react": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"zod": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Minimal DOM types — this module runs in the browser via 'use client'.
|
|
4
|
+
// We don't add "DOM" to tsconfig.lib to avoid polluting Node.js code.
|
|
5
|
+
|
|
6
|
+
declare const window: {
|
|
7
|
+
addEventListener(type: string, listener: (event: never) => void): void;
|
|
8
|
+
removeEventListener(type: string, listener: (event: never) => void): void;
|
|
9
|
+
};
|
|
10
|
+
declare const location: undefined | { href: string };
|
|
11
|
+
declare const navigator: undefined | { userAgent: string };
|
|
12
|
+
interface ErrorEvent {
|
|
13
|
+
error: unknown;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
interface PromiseRejectionEvent {
|
|
17
|
+
reason: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
import { useEffect } from 'react';
|
|
21
|
+
|
|
22
|
+
interface OrkifyErrorCaptureProps {
|
|
23
|
+
/** API route endpoint. Default: `/orkify/errors` */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
/** Max errors to report per page load. Default: 10 */
|
|
26
|
+
maxErrors?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Simple hash for client-side dedup (not cryptographic). */
|
|
30
|
+
function simpleHash(str: string): string {
|
|
31
|
+
let h = 0;
|
|
32
|
+
for (let i = 0; i < str.length; i++) {
|
|
33
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
34
|
+
}
|
|
35
|
+
return h.toString(36);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a Firefox/Safari `fn@file:line:col` stack to V8 `at fn (file:line:col)` format.
|
|
40
|
+
* Chrome/Edge stacks (already V8 format) pass through unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeStack(stack: string): string {
|
|
43
|
+
return stack
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => {
|
|
46
|
+
// Already V8 format: " at fn (...)" or " at ..."
|
|
47
|
+
if (/^\s*at\s+/.test(line)) return line;
|
|
48
|
+
|
|
49
|
+
// Firefox/Safari: "fn@file:line:col" or "@file:line:col"
|
|
50
|
+
const m = line.match(/^([^@]*)@(.+):(\d+):(\d+)$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
const fn = m[1];
|
|
53
|
+
const loc = `${m[2]}:${m[3]}:${m[4]}`;
|
|
54
|
+
return fn ? ` at ${fn} (${loc})` : ` at ${loc}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return line;
|
|
58
|
+
})
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Module-level state for dedup and rate limiting
|
|
63
|
+
const recentHashes = new Map<string, number>();
|
|
64
|
+
let errorCount = 0;
|
|
65
|
+
let configuredEndpoint = '/orkify/errors';
|
|
66
|
+
let configuredMax = 10;
|
|
67
|
+
|
|
68
|
+
/** Flush expired dedup entries (older than 5 seconds). */
|
|
69
|
+
function flushExpired(): void {
|
|
70
|
+
const cutoff = Date.now() - 5_000;
|
|
71
|
+
for (const [hash, ts] of recentHashes) {
|
|
72
|
+
if (ts < cutoff) recentHashes.delete(hash);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Send an error report to the server endpoint. */
|
|
77
|
+
function sendError(
|
|
78
|
+
name: string,
|
|
79
|
+
message: string,
|
|
80
|
+
stack: string,
|
|
81
|
+
errorType: 'browser:error' | 'browser:unhandledRejection'
|
|
82
|
+
): void {
|
|
83
|
+
if (errorCount >= configuredMax) return;
|
|
84
|
+
|
|
85
|
+
const normalizedStack = normalizeStack(stack);
|
|
86
|
+
const hash = simpleHash(normalizedStack);
|
|
87
|
+
|
|
88
|
+
// Dedup: skip if same stack seen in last 5 seconds
|
|
89
|
+
flushExpired();
|
|
90
|
+
if (recentHashes.has(hash)) return;
|
|
91
|
+
recentHashes.set(hash, Date.now());
|
|
92
|
+
errorCount++;
|
|
93
|
+
|
|
94
|
+
void fetch(configuredEndpoint, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
name,
|
|
99
|
+
message,
|
|
100
|
+
stack: normalizedStack,
|
|
101
|
+
errorType,
|
|
102
|
+
url: typeof location !== 'undefined' ? location.href : '',
|
|
103
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
104
|
+
timestamp: Date.now(),
|
|
105
|
+
}),
|
|
106
|
+
keepalive: true,
|
|
107
|
+
}).catch(() => {
|
|
108
|
+
// Silently ignore — never crash the app for error reporting
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Report an error manually. Use this from React Error Boundaries
|
|
114
|
+
* (including Next.js `error.tsx`) where errors don't bubble to `window.onerror`.
|
|
115
|
+
*
|
|
116
|
+
* ```tsx
|
|
117
|
+
* import { reportError } from '@orkify/next/error-capture';
|
|
118
|
+
* useEffect(() => { reportError(error); }, [error]);
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function reportError(error: unknown): void {
|
|
122
|
+
if (!(error instanceof Error)) return;
|
|
123
|
+
sendError(error.name || 'Error', error.message || '', error.stack || '', 'browser:error');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drop-in component that captures browser errors and reports them to orkify.
|
|
128
|
+
* Add to your root layout:
|
|
129
|
+
*
|
|
130
|
+
* ```tsx
|
|
131
|
+
* import { OrkifyErrorCapture } from '@orkify/next/error-capture';
|
|
132
|
+
*
|
|
133
|
+
* <OrkifyErrorCapture />
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function OrkifyErrorCapture({
|
|
137
|
+
endpoint = '/orkify/errors',
|
|
138
|
+
maxErrors = 10,
|
|
139
|
+
}: OrkifyErrorCaptureProps): null {
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
configuredEndpoint = endpoint;
|
|
142
|
+
configuredMax = maxErrors;
|
|
143
|
+
|
|
144
|
+
const onError = (event: ErrorEvent): void => {
|
|
145
|
+
const err = event.error;
|
|
146
|
+
if (err instanceof Error) {
|
|
147
|
+
sendError(err.name || 'Error', err.message || '', err.stack || '', 'browser:error');
|
|
148
|
+
} else {
|
|
149
|
+
sendError('Error', String(event.message || err), '', 'browser:error');
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onRejection = (event: PromiseRejectionEvent): void => {
|
|
154
|
+
const reason = event.reason;
|
|
155
|
+
if (reason instanceof Error) {
|
|
156
|
+
sendError(
|
|
157
|
+
reason.name || 'UnhandledRejection',
|
|
158
|
+
reason.message || '',
|
|
159
|
+
reason.stack || '',
|
|
160
|
+
'browser:unhandledRejection'
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
sendError('UnhandledRejection', String(reason), '', 'browser:unhandledRejection');
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
window.addEventListener('error', onError);
|
|
168
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
window.removeEventListener('error', onError);
|
|
172
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
173
|
+
};
|
|
174
|
+
}, [endpoint, maxErrors]);
|
|
175
|
+
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { extractContext, parseBrowserFrames, type StackFrame } from './utils.js';
|
|
4
|
+
|
|
5
|
+
// ── Rate Limiter ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const MAX_ERRORS_PER_WINDOW = 10;
|
|
8
|
+
const WINDOW_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
interface RateBucket {
|
|
11
|
+
count: number;
|
|
12
|
+
resetAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const rateLimits = new Map<string, RateBucket>();
|
|
16
|
+
|
|
17
|
+
/** Periodic cleanup to prevent unbounded Map growth. */
|
|
18
|
+
let cleanupTimer: null | ReturnType<typeof setInterval> = null;
|
|
19
|
+
|
|
20
|
+
function ensureCleanupTimer(): void {
|
|
21
|
+
if (cleanupTimer) return;
|
|
22
|
+
cleanupTimer = setInterval(() => {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [key, bucket] of rateLimits) {
|
|
25
|
+
if (bucket.resetAt <= now) rateLimits.delete(key);
|
|
26
|
+
}
|
|
27
|
+
if (rateLimits.size === 0 && cleanupTimer) {
|
|
28
|
+
clearInterval(cleanupTimer);
|
|
29
|
+
cleanupTimer = null;
|
|
30
|
+
}
|
|
31
|
+
}, 30_000);
|
|
32
|
+
cleanupTimer.unref();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRateLimited(ip: string): boolean {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const bucket = rateLimits.get(ip);
|
|
38
|
+
|
|
39
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
40
|
+
rateLimits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
41
|
+
ensureCleanupTimer();
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
bucket.count++;
|
|
46
|
+
return bucket.count > MAX_ERRORS_PER_WINDOW;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Validation ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const MAX_BODY_SIZE = 65_536; // 64 KB
|
|
52
|
+
const MAX_STACK_LINES = 100;
|
|
53
|
+
|
|
54
|
+
const browserErrorSchema = z.object({
|
|
55
|
+
name: z.string().max(256),
|
|
56
|
+
message: z.string().max(4096),
|
|
57
|
+
stack: z.string().max(32_768),
|
|
58
|
+
errorType: z.enum(['browser:error', 'browser:unhandledRejection']),
|
|
59
|
+
url: z.string().max(2048),
|
|
60
|
+
userAgent: z.string().max(512),
|
|
61
|
+
timestamp: z.number(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Source Context Builder ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface SourceContextFrame {
|
|
67
|
+
file: string;
|
|
68
|
+
line: number;
|
|
69
|
+
column: number;
|
|
70
|
+
pre: string[];
|
|
71
|
+
target: string;
|
|
72
|
+
post: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildSourceContext(frames: StackFrame[]): SourceContextFrame[] {
|
|
76
|
+
const result: SourceContextFrame[] = [];
|
|
77
|
+
|
|
78
|
+
for (const frame of frames) {
|
|
79
|
+
if (!existsSync(frame.file)) continue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const source = readFileSync(frame.file, 'utf8');
|
|
83
|
+
const context = extractContext(source, frame.line);
|
|
84
|
+
if (!context) continue;
|
|
85
|
+
|
|
86
|
+
result.push({
|
|
87
|
+
file: frame.file,
|
|
88
|
+
line: frame.line,
|
|
89
|
+
column: frame.column,
|
|
90
|
+
pre: context.pre,
|
|
91
|
+
target: context.target,
|
|
92
|
+
post: context.post,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Request Handler ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function getClientIp(request: Request): string {
|
|
105
|
+
// Cloudflare: most reliable, cannot be spoofed by the client
|
|
106
|
+
const cfIp = request.headers.get('cf-connecting-ip');
|
|
107
|
+
if (cfIp) return cfIp;
|
|
108
|
+
// Standard proxy headers
|
|
109
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
110
|
+
if (xff) return xff.split(',')[0].trim();
|
|
111
|
+
const realIp = request.headers.get('x-real-ip');
|
|
112
|
+
if (realIp) return realIp;
|
|
113
|
+
return '127.0.0.1';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Next.js API route handler for browser error reporting.
|
|
118
|
+
*
|
|
119
|
+
* Create `app/orkify/errors/route.ts`:
|
|
120
|
+
* ```ts
|
|
121
|
+
* export { POST } from '@orkify/next/error-handler';
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export async function POST(request: Request): Promise<Response> {
|
|
125
|
+
try {
|
|
126
|
+
// 1. Validate origin — browsers always send Origin on POST requests.
|
|
127
|
+
// This blocks cross-origin abuse and non-browser clients (curl/bots).
|
|
128
|
+
// Behind reverse proxies (nginx, Cloudflare), Host may be internal
|
|
129
|
+
// while Origin is public. X-Forwarded-Host carries the original Host.
|
|
130
|
+
const origin = request.headers.get('origin');
|
|
131
|
+
if (!origin) {
|
|
132
|
+
return Response.json({ ok: false }, { status: 403 });
|
|
133
|
+
}
|
|
134
|
+
const effectiveHost = request.headers.get('x-forwarded-host') || request.headers.get('host');
|
|
135
|
+
if (effectiveHost) {
|
|
136
|
+
try {
|
|
137
|
+
const originHost = new URL(origin).host;
|
|
138
|
+
if (originHost !== effectiveHost) {
|
|
139
|
+
return Response.json({ ok: false }, { status: 403 });
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
return Response.json({ ok: false }, { status: 403 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Check content length
|
|
147
|
+
const contentLength = request.headers.get('content-length');
|
|
148
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) {
|
|
149
|
+
return Response.json({ ok: false }, { status: 413 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Rate limit
|
|
153
|
+
const ip = getClientIp(request);
|
|
154
|
+
if (isRateLimited(ip)) {
|
|
155
|
+
return Response.json({ ok: false }, { status: 429 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Parse and validate body
|
|
159
|
+
const raw = await request.text();
|
|
160
|
+
if (raw.length > MAX_BODY_SIZE) {
|
|
161
|
+
return Response.json({ ok: false }, { status: 413 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let body: unknown;
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return Response.json({ ok: false }, { status: 400 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const parsed = browserErrorSchema.safeParse(body);
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
return Response.json({ ok: false }, { status: 400 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const data = parsed.data;
|
|
177
|
+
|
|
178
|
+
// 5. Truncate stack to max lines
|
|
179
|
+
const stackLines = data.stack.split('\n');
|
|
180
|
+
const truncatedStack =
|
|
181
|
+
stackLines.length > MAX_STACK_LINES
|
|
182
|
+
? stackLines.slice(0, MAX_STACK_LINES).join('\n')
|
|
183
|
+
: data.stack;
|
|
184
|
+
|
|
185
|
+
// 6. Parse browser stack → frames, map URLs to file paths
|
|
186
|
+
const cwd = process.cwd();
|
|
187
|
+
const frames = parseBrowserFrames(truncatedStack, cwd);
|
|
188
|
+
|
|
189
|
+
// 7. Build source context from bundled files on disk
|
|
190
|
+
const sourceContext = frames.length > 0 ? buildSourceContext(frames) : null;
|
|
191
|
+
const topFrame = frames[0] ?? null;
|
|
192
|
+
|
|
193
|
+
// 8. Relay to daemon via IPC
|
|
194
|
+
if (typeof process.send === 'function') {
|
|
195
|
+
process.send({
|
|
196
|
+
__orkify: true,
|
|
197
|
+
type: 'error',
|
|
198
|
+
data: {
|
|
199
|
+
errorType: data.errorType,
|
|
200
|
+
name: data.name,
|
|
201
|
+
message: data.message,
|
|
202
|
+
stack: truncatedStack,
|
|
203
|
+
fingerprint: '', // Daemon recomputes this
|
|
204
|
+
sourceContext: sourceContext && sourceContext.length > 0 ? sourceContext : null,
|
|
205
|
+
topFrame,
|
|
206
|
+
diagnostics: null,
|
|
207
|
+
timestamp: data.timestamp,
|
|
208
|
+
nodeVersion: '',
|
|
209
|
+
pid: 0,
|
|
210
|
+
url: data.url,
|
|
211
|
+
userAgent: data.userAgent,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return Response.json({ ok: true });
|
|
217
|
+
} catch {
|
|
218
|
+
// Never crash the app for error reporting
|
|
219
|
+
return Response.json({ ok: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { cache } from '@orkify/cache';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ISR / route cache handler for Next.js (`cacheHandler` config).
|
|
5
|
+
* Next.js instantiates this with `new`, so it must be a class.
|
|
6
|
+
*
|
|
7
|
+
* Next.js expects `get()` to return `{ value, lastModified }` (the
|
|
8
|
+
* `CacheHandlerValue` interface), so we wrap/unwrap around the raw
|
|
9
|
+
* `IncrementalCacheValue` when storing in orkify/cache.
|
|
10
|
+
*
|
|
11
|
+
* During `next build`, this handler is a no-op — data stored in-memory
|
|
12
|
+
* would be lost when the build process exits, causing invariant errors
|
|
13
|
+
* at runtime when Next.js expects cached pages that no longer exist.
|
|
14
|
+
* Pre-rendered pages are still served from disk (`.next/server/app/`).
|
|
15
|
+
*
|
|
16
|
+
* Headers objects (used by APP_ROUTE responses) don't survive V8
|
|
17
|
+
* structured clone (IPC in cluster mode). We convert them to plain
|
|
18
|
+
* objects on set so they work with both IPC and bracket-notation
|
|
19
|
+
* access in Next.js internals.
|
|
20
|
+
*
|
|
21
|
+
* Cache tags are extracted from the `x-next-cache-tags` header (set by
|
|
22
|
+
* Next.js for APP_PAGE/APP_ROUTE) and registered in orkify's tag index
|
|
23
|
+
* so `revalidateTag()` → `cache.invalidateTag()` can find entries.
|
|
24
|
+
*/
|
|
25
|
+
const IS_BUILD = process.env.NEXT_PHASE === 'phase-production-build';
|
|
26
|
+
const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags';
|
|
27
|
+
|
|
28
|
+
interface StoredEntry {
|
|
29
|
+
lastModified: number;
|
|
30
|
+
value: null | Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Convert Headers to a plain object. Non-Headers pass through. */
|
|
34
|
+
function headersToPlainObject(headers: unknown): Record<string, string> | undefined {
|
|
35
|
+
if (!headers) return undefined;
|
|
36
|
+
if (headers instanceof Headers) {
|
|
37
|
+
return Object.fromEntries([...(headers as Headers).entries()]);
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(headers)) {
|
|
40
|
+
// entries array from a previous version — normalize to plain object
|
|
41
|
+
return Object.fromEntries(headers);
|
|
42
|
+
}
|
|
43
|
+
return headers as Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract cache tags from the x-next-cache-tags header value. */
|
|
47
|
+
function extractTags(headers: Record<string, string> | undefined): string[] | undefined {
|
|
48
|
+
const tagHeader = headers?.[NEXT_CACHE_TAGS_HEADER];
|
|
49
|
+
if (typeof tagHeader === 'string' && tagHeader.length > 0) {
|
|
50
|
+
return tagHeader.split(',');
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default class OrkifyCacheHandler {
|
|
56
|
+
async get(key: string): Promise<null | unknown> {
|
|
57
|
+
if (IS_BUILD) return null;
|
|
58
|
+
|
|
59
|
+
const stored = await cache.getAsync<StoredEntry>(key);
|
|
60
|
+
if (!stored) return null;
|
|
61
|
+
|
|
62
|
+
return stored;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async set(key: string, data: unknown, ctx?: { tags?: string[] }): Promise<void> {
|
|
66
|
+
if (IS_BUILD) return;
|
|
67
|
+
|
|
68
|
+
let value = data as null | Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
// Normalize Headers objects to plain objects for IPC and bracket-notation access
|
|
71
|
+
if (value && 'headers' in value) {
|
|
72
|
+
const plainHeaders = headersToPlainObject(value.headers);
|
|
73
|
+
if (plainHeaders !== value.headers) {
|
|
74
|
+
value = { ...value, headers: plainHeaders };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Gather tags: merge explicit ctx.tags with x-next-cache-tags header (deduplicated)
|
|
79
|
+
const headerTags = value ? extractTags(value.headers as Record<string, string>) : undefined;
|
|
80
|
+
const mergedTags =
|
|
81
|
+
ctx?.tags && headerTags
|
|
82
|
+
? [...new Set([...ctx.tags, ...headerTags])]
|
|
83
|
+
: (ctx?.tags ?? headerTags);
|
|
84
|
+
const tags = mergedTags && mergedTags.length > 0 ? mergedTags : undefined;
|
|
85
|
+
|
|
86
|
+
const entry: StoredEntry = { value, lastModified: Date.now() };
|
|
87
|
+
cache.set(key, entry, tags ? { tags } : undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async revalidateTag(tag: string | string[]): Promise<void> {
|
|
91
|
+
const tags = Array.isArray(tag) ? tag : [tag];
|
|
92
|
+
for (const t of tags) {
|
|
93
|
+
cache.invalidateTag(t);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resetRequestCache(): void {
|
|
98
|
+
// No-op — shared cache, not per-request
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Consume a ReadableStream into a single Buffer. */
|
|
2
|
+
export async function streamToBuffer(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
|
|
3
|
+
const chunks: Uint8Array[] = [];
|
|
4
|
+
const reader = stream.getReader();
|
|
5
|
+
|
|
6
|
+
for (;;) {
|
|
7
|
+
const { done, value } = await reader.read();
|
|
8
|
+
if (done) break;
|
|
9
|
+
chunks.push(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return Buffer.concat(chunks);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Create a single-chunk ReadableStream from a Buffer. */
|
|
16
|
+
export function bufferToStream(buffer: Buffer): ReadableStream<Uint8Array> {
|
|
17
|
+
return new ReadableStream({
|
|
18
|
+
start(controller) {
|
|
19
|
+
controller.enqueue(new Uint8Array(buffer));
|
|
20
|
+
controller.close();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js cache handler interfaces, mirrored here so orkify doesn't
|
|
3
|
+
* depend on `next` as a package dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** What Next.js passes to / expects from the 'use cache' handler. */
|
|
7
|
+
export interface NextCacheEntry {
|
|
8
|
+
expire: number; // seconds, hard max lifetime
|
|
9
|
+
revalidate: number; // seconds, revalidation interval
|
|
10
|
+
stale: number; // seconds
|
|
11
|
+
tags: string[];
|
|
12
|
+
timestamp: number; // ms, when created
|
|
13
|
+
value: ReadableStream<Uint8Array>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 'use cache' handler — 5 methods. */
|
|
17
|
+
export interface NextCacheHandler {
|
|
18
|
+
get(cacheKey: string, softTags: string[]): Promise<NextCacheEntry | undefined>;
|
|
19
|
+
getExpiration(tags: string[]): Promise<number>;
|
|
20
|
+
refreshTags(): Promise<void>;
|
|
21
|
+
set(cacheKey: string, pendingEntry: Promise<NextCacheEntry>): Promise<void>;
|
|
22
|
+
updateTags(tags: string[], durations?: { expire?: number }): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What we actually store in orkify/cache (Buffer instead of stream). */
|
|
26
|
+
export interface StoredCacheEntry {
|
|
27
|
+
buffer: Buffer;
|
|
28
|
+
expire: number;
|
|
29
|
+
revalidate: number;
|
|
30
|
+
stale: number;
|
|
31
|
+
tags: string[];
|
|
32
|
+
timestamp: number;
|
|
33
|
+
}
|