@sansavision/aurora 0.1.0-alpha.20260212.4
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/README.md +4 -0
- package/package.json +17 -0
- package/src/ai-diagnostics.ts +156 -0
- package/src/ai.ts +574 -0
- package/src/analyze.ts +669 -0
- package/src/bin/aurora.ts +15 -0
- package/src/build.ts +431 -0
- package/src/bun-test-shims.d.ts +17 -0
- package/src/create-feature.ts +419 -0
- package/src/create-route.ts +581 -0
- package/src/create.ts +425 -0
- package/src/dev.ts +126 -0
- package/src/devtools.ts +1143 -0
- package/src/doctor.ts +611 -0
- package/src/explain.ts +855 -0
- package/src/help.ts +39 -0
- package/src/index.ts +34 -0
- package/src/init.ts +1011 -0
- package/src/inspect-cache.ts +464 -0
- package/src/lsp-inline-hints.ts +254 -0
- package/src/node-shims.d.ts +26 -0
- package/src/process.d.ts +11 -0
- package/src/query-profiler.ts +520 -0
- package/src/realtime-monitor.ts +389 -0
- package/src/registry.ts +303 -0
- package/src/run.ts +37 -0
- package/src/start.ts +56 -0
- package/src/test.ts +289 -0
- package/templates/basic/README.md +16 -0
- package/templates/basic/package.json +10 -0
- package/templates/basic/src/actions/createMessage.action.server.ts +22 -0
- package/templates/basic/src/lib/auth.server.ts +11 -0
- package/templates/basic/src/queries/listMessages.server.ts +17 -0
- package/templates/basic/src/routes/index.tsx +12 -0
- package/templates/blog/README.md +17 -0
- package/templates/blog/package.json +12 -0
- package/templates/blog/public/assets/og-default.svg +17 -0
- package/templates/blog/src/content/loadPosts.server.ts +22 -0
- package/templates/blog/src/content/posts/hello-world.md +11 -0
- package/templates/blog/src/content/posts/release-notes.md +9 -0
- package/templates/blog/src/routes/index.tsx +22 -0
- package/templates/blog/src/routes/posts/[slug].tsx +19 -0
- package/templates/blog/src/seo/meta.ts +19 -0
- package/templates/dashboard/README.md +18 -0
- package/templates/dashboard/package.json +10 -0
- package/templates/dashboard/src/actions/acknowledgeAlert.action.server.ts +6 -0
- package/templates/dashboard/src/queries/getDashboardMetrics.server.ts +30 -0
- package/templates/dashboard/src/realtime/useDashboardRealtime.client.ts +13 -0
- package/templates/dashboard/src/routes/index.tsx +19 -0
- package/templates/dashboard/src/widgets/DataGrid.client.ts +8 -0
- package/templates/dashboard/src/widgets/MetricChart.client.ts +8 -0
- package/templates/desktop/README.md +18 -0
- package/templates/desktop/package.json +11 -0
- package/templates/desktop/src/actions/saveDesktopPreference.action.server.ts +28 -0
- package/templates/desktop/src/desktop/secureStorage.client.ts +20 -0
- package/templates/desktop/src/desktop/tauriBridge.client.ts +14 -0
- package/templates/desktop/src/queries/getDesktopSyncStatus.server.ts +9 -0
- package/templates/desktop/src/routes/index.tsx +27 -0
- package/templates/desktop/src/sync/offlineSyncBoundary.server.ts +27 -0
- package/templates/feature-skeleton/README.md +13 -0
- package/templates/feature-skeleton/actions/createFeature.action.server.ts +19 -0
- package/templates/feature-skeleton/index.ts +8 -0
- package/templates/feature-skeleton/queries/listFeature.server.ts +15 -0
- package/templates/feature-skeleton/realtime/useFeatureRealtime.client.ts +16 -0
- package/templates/feature-skeleton/template.manifest.json +15 -0
- package/templates/feature-skeleton/ui/FeatureView.client.tsx +14 -0
- package/templates/mobile/README.md +17 -0
- package/templates/mobile/package.json +11 -0
- package/templates/mobile/src/mobile/auth/session-handoff.client.ts +69 -0
- package/templates/mobile/src/mobile/generated/mobile-api-sdk.ts +62 -0
- package/templates/mobile/src/mobile/transport/mobile-api-transport.client.ts +122 -0
- package/templates/mobile/src/routes/index.tsx +134 -0
- package/templates/monorepo/README.md +18 -0
- package/templates/monorepo/apps/web/package.json +9 -0
- package/templates/monorepo/apps/web/src/routes/index.tsx +1 -0
- package/templates/monorepo/package.json +13 -0
- package/templates/monorepo/packages/shared/README.md +3 -0
- package/templates/monorepo/packages/ui/README.md +3 -0
- package/templates/saas/README.md +17 -0
- package/templates/saas/package.json +10 -0
- package/templates/saas/src/admin/getDashboard.server.ts +18 -0
- package/templates/saas/src/auth/session.server.ts +13 -0
- package/templates/saas/src/billing/checkout.server.ts +11 -0
- package/templates/saas/src/email/sendWelcome.server.ts +8 -0
- package/templates/saas/src/realtime/notifications.server.ts +8 -0
- package/templates/saas/src/routes/index.tsx +20 -0
- package/test/ai.test.ts +94 -0
- package/test/analyze.test.ts +301 -0
- package/test/build.test.ts +135 -0
- package/test/create-feature.test.ts +145 -0
- package/test/create-route.test.ts +117 -0
- package/test/create.test.ts +222 -0
- package/test/dev.test.ts +52 -0
- package/test/devtools.test.ts +130 -0
- package/test/doctor.test.ts +129 -0
- package/test/explain.test.ts +232 -0
- package/test/feature-skeleton.test.ts +53 -0
- package/test/fixtures/analyze/cache-input.invalid.json +1 -0
- package/test/fixtures/analyze/cache-input.missing-keyhash.v1.json +10 -0
- package/test/fixtures/analyze/cache-input.unsupported-version.v2.json +10 -0
- package/test/fixtures/analyze/cache-input.v1.json +12 -0
- package/test/fixtures/analyze/compiler-manifest/manifest.json +11 -0
- package/test/fixtures/analyze/guardrails-input.unsupported-version.v2.json +4 -0
- package/test/fixtures/analyze/guardrails-input.v1.json +49 -0
- package/test/fixtures/analyze/query-input.invalid-cache-status.v1.json +11 -0
- package/test/fixtures/analyze/query-input.unsupported-version.v2.json +11 -0
- package/test/fixtures/analyze/query-input.v1.json +18 -0
- package/test/fixtures/analyze/realtime-input.missing-lag-p95.v1.json +10 -0
- package/test/fixtures/analyze/realtime-input.unsupported-version.v2.json +8 -0
- package/test/fixtures/analyze/realtime-input.v1.json +12 -0
- package/test/fixtures/cache-inspector/cache-input.v1.json +23 -0
- package/test/fixtures/cache-inspector/invalid.json +1 -0
- package/test/fixtures/cache-inspector/snapshot.v1.json +34 -0
- package/test/fixtures/cache-inspector/unsupported-version.v2.json +13 -0
- package/test/fixtures/devtools/healthy.v1.json +130 -0
- package/test/fixtures/devtools/invalid.json +1 -0
- package/test/fixtures/devtools/unsupported-version.v2.json +8 -0
- package/test/fixtures/devtools/warn.v1.json +114 -0
- package/test/fixtures/doctor/clean/src/page.tsx +3 -0
- package/test/fixtures/doctor/findings/src/accessibility.client.tsx +7 -0
- package/test/fixtures/doctor/findings/src/migration.config.ts +3 -0
- package/test/fixtures/doctor/findings/src/page.client.tsx +5 -0
- package/test/fixtures/doctor/findings/src/perf.server.ts +15 -0
- package/test/fixtures/doctor/findings/src/routes.js +3 -0
- package/test/fixtures/doctor/findings/src/security.server.ts +7 -0
- package/test/fixtures/doctor/findings/src/users.server.ts +3 -0
- package/test/fixtures/doctor/governance/src/features/analytics/OWNERS.ts +2 -0
- package/test/fixtures/doctor/governance/src/features/analytics/page.tsx +3 -0
- package/test/fixtures/doctor/governance/src/features/billing/page.tsx +3 -0
- package/test/fixtures/explain/invalid.json +1 -0
- package/test/fixtures/explain/module-report.unsupported-version.v2.json +6 -0
- package/test/fixtures/explain/module-report.v1.json +72 -0
- package/test/fixtures/query-profiler/healthy.v1.json +11 -0
- package/test/fixtures/query-profiler/invalid.json +1 -0
- package/test/fixtures/query-profiler/unsupported-version.v2.json +6 -0
- package/test/fixtures/query-profiler/warning.v1.json +10 -0
- package/test/fixtures/realtime-monitor/healthy.v1.json +8 -0
- package/test/fixtures/realtime-monitor/invalid.json +1 -0
- package/test/fixtures/realtime-monitor/unsupported-version.v2.json +8 -0
- package/test/fixtures/realtime-monitor/warning.v1.json +8 -0
- package/test/help-parity.test.ts +104 -0
- package/test/init.test.ts +164 -0
- package/test/inspect-cache.test.ts +112 -0
- package/test/lsp-inline-hints.test.ts +65 -0
- package/test/query-profiler.test.ts +123 -0
- package/test/realtime-monitor.test.ts +115 -0
- package/test/registry.test.ts +41 -0
- package/test/start.test.ts +23 -0
- package/test/test-command.test.ts +65 -0
- package/tsconfig.json +19 -0
package/src/devtools.ts
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { type CommandContext, type CommandResult } from "./registry";
|
|
5
|
+
|
|
6
|
+
type DevtoolsFormat = "text" | "json";
|
|
7
|
+
type PanelStatus = "ok" | "warn";
|
|
8
|
+
type DevtoolsPanelName = "explain" | "waterfall" | "performance" | "realtime" | "auth";
|
|
9
|
+
|
|
10
|
+
interface DevtoolsOptions {
|
|
11
|
+
inputPath: string;
|
|
12
|
+
format: DevtoolsFormat;
|
|
13
|
+
selectedPanels: readonly DevtoolsPanelName[];
|
|
14
|
+
maxWaterfallBlockingMs: number;
|
|
15
|
+
maxLagP95Ms: number;
|
|
16
|
+
maxDropRatio: number;
|
|
17
|
+
minRouteScore: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PanelIssue {
|
|
21
|
+
code: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DevtoolsExplainPanel {
|
|
26
|
+
name: "explain";
|
|
27
|
+
status: PanelStatus;
|
|
28
|
+
module: string;
|
|
29
|
+
queries: number;
|
|
30
|
+
actions: number;
|
|
31
|
+
realtimeQueries: number;
|
|
32
|
+
warnings: readonly string[];
|
|
33
|
+
authCoverage: {
|
|
34
|
+
queryAuth: readonly string[];
|
|
35
|
+
actionAuth: readonly string[];
|
|
36
|
+
missingQueryAuth: number;
|
|
37
|
+
missingActionAuth: number;
|
|
38
|
+
};
|
|
39
|
+
issues: readonly PanelIssue[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WaterfallPhase {
|
|
43
|
+
name: string;
|
|
44
|
+
durationMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface WaterfallOperation {
|
|
48
|
+
name: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
startMs: number;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
blocking: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DevtoolsWaterfallPanel {
|
|
56
|
+
name: "waterfall";
|
|
57
|
+
status: PanelStatus;
|
|
58
|
+
requestId?: string;
|
|
59
|
+
operations: number;
|
|
60
|
+
totalDurationMs: number;
|
|
61
|
+
sequentialOperations: number;
|
|
62
|
+
parallelOperations: number;
|
|
63
|
+
blockingOverThreshold: number;
|
|
64
|
+
longestOperation?: {
|
|
65
|
+
name: string;
|
|
66
|
+
kind: string;
|
|
67
|
+
durationMs: number;
|
|
68
|
+
};
|
|
69
|
+
phases: readonly WaterfallPhase[];
|
|
70
|
+
issues: readonly PanelIssue[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface PerformanceRouteScore {
|
|
74
|
+
route: string;
|
|
75
|
+
score: number;
|
|
76
|
+
jsBytes: number;
|
|
77
|
+
payloadBytes: number;
|
|
78
|
+
lcpMs: number;
|
|
79
|
+
notes: readonly string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface DevtoolsPerformancePanel {
|
|
83
|
+
name: "performance";
|
|
84
|
+
status: PanelStatus;
|
|
85
|
+
averageScore: number;
|
|
86
|
+
minimumScore: number;
|
|
87
|
+
routes: readonly PerformanceRouteScore[];
|
|
88
|
+
issues: readonly PanelIssue[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface RealtimeSubscriptionSummary {
|
|
92
|
+
tag: string;
|
|
93
|
+
channel: string;
|
|
94
|
+
state: string;
|
|
95
|
+
authScope: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface DevtoolsRealtimePanel {
|
|
99
|
+
name: "realtime";
|
|
100
|
+
status: PanelStatus;
|
|
101
|
+
connectionState: string;
|
|
102
|
+
eventsPerSecond: number;
|
|
103
|
+
lagP95Ms: number;
|
|
104
|
+
droppedRatio: number;
|
|
105
|
+
activeSubscriptions: number;
|
|
106
|
+
authScopedSubscriptions: Record<string, number>;
|
|
107
|
+
subscriptions: readonly RealtimeSubscriptionSummary[];
|
|
108
|
+
issues: readonly PanelIssue[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface DevtoolsAuthPanel {
|
|
112
|
+
name: "auth";
|
|
113
|
+
status: PanelStatus;
|
|
114
|
+
sessionStatus: string;
|
|
115
|
+
routeAuth: string;
|
|
116
|
+
userId?: string;
|
|
117
|
+
role?: string;
|
|
118
|
+
permissions: readonly string[];
|
|
119
|
+
tokenExpiresInSec?: number;
|
|
120
|
+
issues: readonly PanelIssue[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type DevtoolsPanel =
|
|
124
|
+
| DevtoolsExplainPanel
|
|
125
|
+
| DevtoolsWaterfallPanel
|
|
126
|
+
| DevtoolsPerformancePanel
|
|
127
|
+
| DevtoolsRealtimePanel
|
|
128
|
+
| DevtoolsAuthPanel;
|
|
129
|
+
|
|
130
|
+
interface DevtoolsReport {
|
|
131
|
+
mode: "devtools";
|
|
132
|
+
schemaVersion: 1;
|
|
133
|
+
projectRoot: string;
|
|
134
|
+
inputPath: string;
|
|
135
|
+
status: PanelStatus;
|
|
136
|
+
selectedPanels: readonly DevtoolsPanelName[];
|
|
137
|
+
panels: readonly DevtoolsPanel[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const VALID_PANELS: readonly DevtoolsPanelName[] = [
|
|
141
|
+
"explain",
|
|
142
|
+
"waterfall",
|
|
143
|
+
"performance",
|
|
144
|
+
"realtime",
|
|
145
|
+
"auth",
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const DEFAULT_MAX_WATERFALL_BLOCKING_MS = 120;
|
|
149
|
+
const DEFAULT_MAX_LAG_P95_MS = 250;
|
|
150
|
+
const DEFAULT_MAX_DROP_RATIO = 0.05;
|
|
151
|
+
const DEFAULT_MIN_ROUTE_SCORE = 80;
|
|
152
|
+
|
|
153
|
+
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
154
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value as Record<string, unknown>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isCommandResult(value: unknown): value is CommandResult {
|
|
162
|
+
const source = toRecord(value);
|
|
163
|
+
return Boolean(source && typeof source.exitCode === "number");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseNonNegativeNumber(raw: string | undefined, flag: string): number | CommandResult {
|
|
167
|
+
if (!raw) {
|
|
168
|
+
return {
|
|
169
|
+
exitCode: 2,
|
|
170
|
+
stderr: `aurora devtools: ${flag} requires a numeric value`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const parsed = Number(raw);
|
|
175
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
176
|
+
return {
|
|
177
|
+
exitCode: 2,
|
|
178
|
+
stderr: `aurora devtools: ${flag} must be a non-negative number`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parsePanelSelection(raw: string | undefined): readonly DevtoolsPanelName[] | CommandResult {
|
|
186
|
+
if (!raw) {
|
|
187
|
+
return {
|
|
188
|
+
exitCode: 2,
|
|
189
|
+
stderr: "aurora devtools: --panel requires one or more panel names",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tokens = raw
|
|
194
|
+
.split(",")
|
|
195
|
+
.map((token) => token.trim())
|
|
196
|
+
.filter((token) => token.length > 0);
|
|
197
|
+
|
|
198
|
+
if (tokens.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
exitCode: 2,
|
|
201
|
+
stderr: "aurora devtools: --panel requires one or more panel names",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unique = new Set<DevtoolsPanelName>();
|
|
206
|
+
for (const token of tokens) {
|
|
207
|
+
if (!VALID_PANELS.includes(token as DevtoolsPanelName)) {
|
|
208
|
+
return {
|
|
209
|
+
exitCode: 2,
|
|
210
|
+
stderr:
|
|
211
|
+
`aurora devtools: invalid panel '${token}'. ` +
|
|
212
|
+
`Expected one of ${VALID_PANELS.join(", ")}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
unique.add(token as DevtoolsPanelName);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return [...unique];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseDevtoolsOptions(args: ReadonlyArray<string>): DevtoolsOptions | CommandResult {
|
|
223
|
+
const selected = new Set<DevtoolsPanelName>();
|
|
224
|
+
const options: DevtoolsOptions = {
|
|
225
|
+
inputPath: "",
|
|
226
|
+
format: "text",
|
|
227
|
+
selectedPanels: VALID_PANELS,
|
|
228
|
+
maxWaterfallBlockingMs: DEFAULT_MAX_WATERFALL_BLOCKING_MS,
|
|
229
|
+
maxLagP95Ms: DEFAULT_MAX_LAG_P95_MS,
|
|
230
|
+
maxDropRatio: DEFAULT_MAX_DROP_RATIO,
|
|
231
|
+
minRouteScore: DEFAULT_MIN_ROUTE_SCORE,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
235
|
+
const arg = args[i];
|
|
236
|
+
if (arg === "--input") {
|
|
237
|
+
const value = args[i + 1];
|
|
238
|
+
if (!value) {
|
|
239
|
+
return {
|
|
240
|
+
exitCode: 2,
|
|
241
|
+
stderr: "aurora devtools: --input requires a path value",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
options.inputPath = value;
|
|
246
|
+
i += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (arg === "--format") {
|
|
251
|
+
const value = args[i + 1];
|
|
252
|
+
if (!value) {
|
|
253
|
+
return {
|
|
254
|
+
exitCode: 2,
|
|
255
|
+
stderr: "aurora devtools: --format requires 'text' or 'json'",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (value !== "text" && value !== "json") {
|
|
260
|
+
return {
|
|
261
|
+
exitCode: 2,
|
|
262
|
+
stderr: `aurora devtools: invalid format '${value}'. Expected 'text' or 'json'`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
options.format = value;
|
|
267
|
+
i += 1;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (arg === "--panel") {
|
|
272
|
+
const parsedPanels = parsePanelSelection(args[i + 1]);
|
|
273
|
+
if (isCommandResult(parsedPanels)) {
|
|
274
|
+
return parsedPanels;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const panel of parsedPanels) {
|
|
278
|
+
selected.add(panel);
|
|
279
|
+
}
|
|
280
|
+
i += 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (arg === "--max-waterfall-blocking-ms") {
|
|
285
|
+
const parsed = parseNonNegativeNumber(args[i + 1], "--max-waterfall-blocking-ms");
|
|
286
|
+
if (typeof parsed !== "number") {
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
289
|
+
options.maxWaterfallBlockingMs = parsed;
|
|
290
|
+
i += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (arg === "--max-lag-p95-ms") {
|
|
295
|
+
const parsed = parseNonNegativeNumber(args[i + 1], "--max-lag-p95-ms");
|
|
296
|
+
if (typeof parsed !== "number") {
|
|
297
|
+
return parsed;
|
|
298
|
+
}
|
|
299
|
+
options.maxLagP95Ms = parsed;
|
|
300
|
+
i += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (arg === "--max-drop-ratio") {
|
|
305
|
+
const parsed = parseNonNegativeNumber(args[i + 1], "--max-drop-ratio");
|
|
306
|
+
if (typeof parsed !== "number") {
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
309
|
+
options.maxDropRatio = parsed;
|
|
310
|
+
i += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (arg === "--min-route-score") {
|
|
315
|
+
const parsed = parseNonNegativeNumber(args[i + 1], "--min-route-score");
|
|
316
|
+
if (typeof parsed !== "number") {
|
|
317
|
+
return parsed;
|
|
318
|
+
}
|
|
319
|
+
options.minRouteScore = parsed;
|
|
320
|
+
i += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
exitCode: 2,
|
|
326
|
+
stderr: `aurora devtools: unknown option '${arg}'`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!options.inputPath) {
|
|
331
|
+
return {
|
|
332
|
+
exitCode: 2,
|
|
333
|
+
stderr: "aurora devtools: --input is required",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (selected.size > 0) {
|
|
338
|
+
options.selectedPanels = [...selected];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return options;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readInputJson(inputPath: string, context: CommandContext): unknown | CommandResult {
|
|
345
|
+
const absolutePath = resolve(context.cwd, inputPath);
|
|
346
|
+
let raw = "";
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
raw = readFileSync(absolutePath, "utf8");
|
|
350
|
+
} catch {
|
|
351
|
+
return {
|
|
352
|
+
exitCode: 1,
|
|
353
|
+
stderr: `aurora devtools: unable to read input at ${inputPath}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(raw);
|
|
359
|
+
} catch {
|
|
360
|
+
return {
|
|
361
|
+
exitCode: 1,
|
|
362
|
+
stderr: `aurora devtools: invalid json input at ${inputPath}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseOptionalString(
|
|
368
|
+
source: Record<string, unknown>,
|
|
369
|
+
fields: ReadonlyArray<string>,
|
|
370
|
+
): string | undefined {
|
|
371
|
+
for (const field of fields) {
|
|
372
|
+
const value = source[field];
|
|
373
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
374
|
+
return value.trim();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function parseOptionalNumber(
|
|
382
|
+
source: Record<string, unknown>,
|
|
383
|
+
fields: ReadonlyArray<string>,
|
|
384
|
+
): number | undefined {
|
|
385
|
+
for (const field of fields) {
|
|
386
|
+
const value = source[field];
|
|
387
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseRequiredString(
|
|
396
|
+
source: Record<string, unknown>,
|
|
397
|
+
fields: ReadonlyArray<string>,
|
|
398
|
+
label: string,
|
|
399
|
+
): string {
|
|
400
|
+
const parsed = parseOptionalString(source, fields);
|
|
401
|
+
if (!parsed) {
|
|
402
|
+
throw new Error(`${label} is required`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return parsed;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function parseRequiredNonNegativeNumber(
|
|
409
|
+
source: Record<string, unknown>,
|
|
410
|
+
fields: ReadonlyArray<string>,
|
|
411
|
+
label: string,
|
|
412
|
+
): number {
|
|
413
|
+
const parsed = parseOptionalNumber(source, fields);
|
|
414
|
+
if (parsed === undefined || parsed < 0) {
|
|
415
|
+
throw new Error(`${label} must be a non-negative number`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return parsed;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function parseStringArray(raw: unknown): string[] {
|
|
422
|
+
if (typeof raw === "string") {
|
|
423
|
+
return raw.trim().length > 0 ? [raw.trim()] : [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!Array.isArray(raw)) {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const deduped = new Set<string>();
|
|
431
|
+
for (const value of raw) {
|
|
432
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
433
|
+
deduped.add(value.trim());
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return [...deduped];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function parseFieldValueAsString(raw: unknown): string | undefined {
|
|
441
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
442
|
+
return raw.trim();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const source = toRecord(raw);
|
|
446
|
+
if (!source) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const value = source.value;
|
|
451
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
452
|
+
return value.trim();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function buildExplainPanel(rawPanel: unknown): DevtoolsExplainPanel {
|
|
459
|
+
const source = toRecord(rawPanel);
|
|
460
|
+
if (!source) {
|
|
461
|
+
throw new Error("panel 'explain' must be an object");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const modulePath = parseOptionalString(source, ["module", "modulePath"]) ?? "unknown";
|
|
465
|
+
const queryEntries = Array.isArray(source.queries) ? source.queries : [];
|
|
466
|
+
const actionEntries = Array.isArray(source.actions) ? source.actions : [];
|
|
467
|
+
const warnings = parseStringArray(source.warnings);
|
|
468
|
+
|
|
469
|
+
let realtimeQueries = 0;
|
|
470
|
+
let missingQueryAuth = 0;
|
|
471
|
+
let missingActionAuth = 0;
|
|
472
|
+
const queryAuth = new Set<string>();
|
|
473
|
+
const actionAuth = new Set<string>();
|
|
474
|
+
|
|
475
|
+
for (const entry of queryEntries) {
|
|
476
|
+
const record = toRecord(entry);
|
|
477
|
+
if (!record) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const auth = parseFieldValueAsString(record.auth);
|
|
482
|
+
if (auth) {
|
|
483
|
+
queryAuth.add(auth);
|
|
484
|
+
} else {
|
|
485
|
+
missingQueryAuth += 1;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const realtime = parseFieldValueAsString(record.realtime);
|
|
489
|
+
if (realtime && realtime !== "none") {
|
|
490
|
+
realtimeQueries += 1;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const entry of actionEntries) {
|
|
495
|
+
const record = toRecord(entry);
|
|
496
|
+
if (!record) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const auth = parseFieldValueAsString(record.auth);
|
|
501
|
+
if (auth) {
|
|
502
|
+
actionAuth.add(auth);
|
|
503
|
+
} else {
|
|
504
|
+
missingActionAuth += 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const issues: PanelIssue[] = [];
|
|
509
|
+
if (warnings.length > 0) {
|
|
510
|
+
issues.push({
|
|
511
|
+
code: "EXPLAIN_WARNINGS_PRESENT",
|
|
512
|
+
message: `explain snapshot includes ${warnings.length} warning(s)`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
if (missingQueryAuth > 0) {
|
|
516
|
+
issues.push({
|
|
517
|
+
code: "QUERY_AUTH_MISSING",
|
|
518
|
+
message: `${missingQueryAuth} query entry(ies) missing auth visibility`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (missingActionAuth > 0) {
|
|
522
|
+
issues.push({
|
|
523
|
+
code: "ACTION_AUTH_MISSING",
|
|
524
|
+
message: `${missingActionAuth} action entry(ies) missing auth visibility`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
name: "explain",
|
|
530
|
+
status: issues.length > 0 ? "warn" : "ok",
|
|
531
|
+
module: modulePath,
|
|
532
|
+
queries: queryEntries.length,
|
|
533
|
+
actions: actionEntries.length,
|
|
534
|
+
realtimeQueries,
|
|
535
|
+
warnings,
|
|
536
|
+
authCoverage: {
|
|
537
|
+
queryAuth: [...queryAuth].sort(),
|
|
538
|
+
actionAuth: [...actionAuth].sort(),
|
|
539
|
+
missingQueryAuth,
|
|
540
|
+
missingActionAuth,
|
|
541
|
+
},
|
|
542
|
+
issues,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function parseWaterfallPhase(raw: unknown, index: number): WaterfallPhase {
|
|
547
|
+
const source = toRecord(raw);
|
|
548
|
+
if (!source) {
|
|
549
|
+
throw new Error(`waterfall.phases[${index}] must be an object`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
name: parseRequiredString(source, ["name", "phase"], `waterfall.phases[${index}].name`),
|
|
554
|
+
durationMs: parseRequiredNonNegativeNumber(
|
|
555
|
+
source,
|
|
556
|
+
["durationMs", "duration", "ms"],
|
|
557
|
+
`waterfall.phases[${index}].durationMs`,
|
|
558
|
+
),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseWaterfallOperation(raw: unknown, index: number): WaterfallOperation {
|
|
563
|
+
const source = toRecord(raw);
|
|
564
|
+
if (!source) {
|
|
565
|
+
throw new Error(`waterfall.operations[${index}] must be an object`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const kind = parseOptionalString(source, ["kind", "type"]) ?? "unknown";
|
|
569
|
+
const isBlockingByDefault = kind === "query" || kind === "action";
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
name: parseRequiredString(source, ["name", "operation"], `waterfall.operations[${index}].name`),
|
|
573
|
+
kind,
|
|
574
|
+
startMs: parseRequiredNonNegativeNumber(
|
|
575
|
+
source,
|
|
576
|
+
["startMs", "start", "startedAtMs"],
|
|
577
|
+
`waterfall.operations[${index}].startMs`,
|
|
578
|
+
),
|
|
579
|
+
durationMs: parseRequiredNonNegativeNumber(
|
|
580
|
+
source,
|
|
581
|
+
["durationMs", "duration", "ms"],
|
|
582
|
+
`waterfall.operations[${index}].durationMs`,
|
|
583
|
+
),
|
|
584
|
+
blocking:
|
|
585
|
+
typeof source.blocking === "boolean"
|
|
586
|
+
? source.blocking
|
|
587
|
+
: isBlockingByDefault,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function buildWaterfallPanel(
|
|
592
|
+
rawPanel: unknown,
|
|
593
|
+
options: DevtoolsOptions,
|
|
594
|
+
): DevtoolsWaterfallPanel {
|
|
595
|
+
const source = toRecord(rawPanel);
|
|
596
|
+
if (!source) {
|
|
597
|
+
throw new Error("panel 'waterfall' must be an object");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const rawOperations = Array.isArray(source.operations) ? source.operations : [];
|
|
601
|
+
const operations = rawOperations.map((entry, index) => parseWaterfallOperation(entry, index));
|
|
602
|
+
const sortedOperations = [...operations].sort((left, right) => {
|
|
603
|
+
if (left.startMs !== right.startMs) {
|
|
604
|
+
return left.startMs - right.startMs;
|
|
605
|
+
}
|
|
606
|
+
return left.name.localeCompare(right.name);
|
|
607
|
+
});
|
|
608
|
+
const phases = Array.isArray(source.phases)
|
|
609
|
+
? source.phases.map((phase, index) => parseWaterfallPhase(phase, index))
|
|
610
|
+
: [];
|
|
611
|
+
|
|
612
|
+
const issues: PanelIssue[] = [];
|
|
613
|
+
if (sortedOperations.length === 0) {
|
|
614
|
+
issues.push({
|
|
615
|
+
code: "NO_OPERATIONS",
|
|
616
|
+
message: "waterfall panel requires at least one operation",
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let sequentialOperations = 0;
|
|
621
|
+
let parallelOperations = 0;
|
|
622
|
+
for (let index = 1; index < sortedOperations.length; index += 1) {
|
|
623
|
+
const previous = sortedOperations[index - 1];
|
|
624
|
+
const current = sortedOperations[index];
|
|
625
|
+
const previousEnd = previous.startMs + previous.durationMs;
|
|
626
|
+
if (current.startMs >= previousEnd) {
|
|
627
|
+
sequentialOperations += 1;
|
|
628
|
+
} else {
|
|
629
|
+
parallelOperations += 1;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const blockingOverThreshold = sortedOperations.filter(
|
|
634
|
+
(operation) => operation.blocking && operation.durationMs > options.maxWaterfallBlockingMs,
|
|
635
|
+
).length;
|
|
636
|
+
if (blockingOverThreshold > 0) {
|
|
637
|
+
issues.push({
|
|
638
|
+
code: "BLOCKING_OPERATION_SLOW",
|
|
639
|
+
message:
|
|
640
|
+
`${blockingOverThreshold} blocking operation(s) exceed ` +
|
|
641
|
+
`${options.maxWaterfallBlockingMs}ms`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (sequentialOperations > parallelOperations && sortedOperations.length >= 3) {
|
|
646
|
+
issues.push({
|
|
647
|
+
code: "SEQUENTIAL_CHAIN_DETECTED",
|
|
648
|
+
message: "sequential operations dominate request timeline",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const minStart = sortedOperations[0]?.startMs ?? 0;
|
|
653
|
+
const maxEnd = sortedOperations.reduce(
|
|
654
|
+
(latest, operation) => Math.max(latest, operation.startMs + operation.durationMs),
|
|
655
|
+
minStart,
|
|
656
|
+
);
|
|
657
|
+
const totalDurationMs = maxEnd - minStart;
|
|
658
|
+
|
|
659
|
+
const longestOperation = sortedOperations.reduce<WaterfallOperation | undefined>(
|
|
660
|
+
(longest, operation) => {
|
|
661
|
+
if (!longest || operation.durationMs > longest.durationMs) {
|
|
662
|
+
return operation;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return longest;
|
|
666
|
+
},
|
|
667
|
+
undefined,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
name: "waterfall",
|
|
672
|
+
status: issues.length > 0 ? "warn" : "ok",
|
|
673
|
+
requestId: parseOptionalString(source, ["requestId", "request"]),
|
|
674
|
+
operations: sortedOperations.length,
|
|
675
|
+
totalDurationMs,
|
|
676
|
+
sequentialOperations,
|
|
677
|
+
parallelOperations,
|
|
678
|
+
blockingOverThreshold,
|
|
679
|
+
longestOperation: longestOperation
|
|
680
|
+
? {
|
|
681
|
+
name: longestOperation.name,
|
|
682
|
+
kind: longestOperation.kind,
|
|
683
|
+
durationMs: longestOperation.durationMs,
|
|
684
|
+
}
|
|
685
|
+
: undefined,
|
|
686
|
+
phases,
|
|
687
|
+
issues,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function computeRouteScore(
|
|
692
|
+
jsBytes: number,
|
|
693
|
+
payloadBytes: number,
|
|
694
|
+
lcpMs: number,
|
|
695
|
+
): number {
|
|
696
|
+
const jsPenalty = Math.max(0, jsBytes - 50_000) / 5_000;
|
|
697
|
+
const payloadPenalty = Math.max(0, payloadBytes - 4_000) / 500;
|
|
698
|
+
const lcpPenalty = Math.max(0, lcpMs - 250) / 35;
|
|
699
|
+
const score = 100 - (jsPenalty + payloadPenalty + lcpPenalty);
|
|
700
|
+
return Math.round(Math.max(0, Math.min(100, score)));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function buildPerformancePanel(
|
|
704
|
+
rawPanel: unknown,
|
|
705
|
+
options: DevtoolsOptions,
|
|
706
|
+
): DevtoolsPerformancePanel {
|
|
707
|
+
const source = toRecord(rawPanel);
|
|
708
|
+
if (!source) {
|
|
709
|
+
throw new Error("panel 'performance' must be an object");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const rawRoutes = Array.isArray(source.routes) ? source.routes : [];
|
|
713
|
+
const routes: PerformanceRouteScore[] = rawRoutes.map((entry, index) => {
|
|
714
|
+
const routeSource = toRecord(entry);
|
|
715
|
+
if (!routeSource) {
|
|
716
|
+
throw new Error(`performance.routes[${index}] must be an object`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const route = parseRequiredString(routeSource, ["route", "path"], `performance.routes[${index}].route`);
|
|
720
|
+
const jsBytes = parseRequiredNonNegativeNumber(
|
|
721
|
+
routeSource,
|
|
722
|
+
["jsBytes", "bundleBytes", "bundle"],
|
|
723
|
+
`performance.routes[${index}].jsBytes`,
|
|
724
|
+
);
|
|
725
|
+
const payloadBytes = parseRequiredNonNegativeNumber(
|
|
726
|
+
routeSource,
|
|
727
|
+
["payloadBytes", "dataBytes", "payload"],
|
|
728
|
+
`performance.routes[${index}].payloadBytes`,
|
|
729
|
+
);
|
|
730
|
+
const lcpMs = parseRequiredNonNegativeNumber(
|
|
731
|
+
routeSource,
|
|
732
|
+
["lcpMs", "lcp", "lcpEstimateMs"],
|
|
733
|
+
`performance.routes[${index}].lcpMs`,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const notes: string[] = [];
|
|
737
|
+
if (jsBytes > 150_000) {
|
|
738
|
+
notes.push("large bundle");
|
|
739
|
+
}
|
|
740
|
+
if (payloadBytes > 20_000) {
|
|
741
|
+
notes.push("large payload");
|
|
742
|
+
}
|
|
743
|
+
if (lcpMs > 700) {
|
|
744
|
+
notes.push("slow LCP");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
route,
|
|
749
|
+
score: computeRouteScore(jsBytes, payloadBytes, lcpMs),
|
|
750
|
+
jsBytes,
|
|
751
|
+
payloadBytes,
|
|
752
|
+
lcpMs,
|
|
753
|
+
notes,
|
|
754
|
+
};
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
const issues: PanelIssue[] = [];
|
|
758
|
+
if (routes.length === 0) {
|
|
759
|
+
issues.push({
|
|
760
|
+
code: "NO_ROUTES",
|
|
761
|
+
message: "performance panel requires routes[]",
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const lowRoutes = routes.filter((route) => route.score < options.minRouteScore);
|
|
766
|
+
if (lowRoutes.length > 0) {
|
|
767
|
+
issues.push({
|
|
768
|
+
code: "ROUTE_SCORE_BELOW_MIN",
|
|
769
|
+
message:
|
|
770
|
+
`${lowRoutes.length} route(s) below score ${options.minRouteScore}: ` +
|
|
771
|
+
`${lowRoutes.map((route) => route.route).join(", ")}`,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const averageScore =
|
|
776
|
+
routes.length === 0
|
|
777
|
+
? 0
|
|
778
|
+
: Math.round(routes.reduce((total, route) => total + route.score, 0) / routes.length);
|
|
779
|
+
const minimumScore =
|
|
780
|
+
routes.length === 0
|
|
781
|
+
? 0
|
|
782
|
+
: routes.reduce((lowest, route) => Math.min(lowest, route.score), routes[0]?.score ?? 0);
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
name: "performance",
|
|
786
|
+
status: issues.length > 0 ? "warn" : "ok",
|
|
787
|
+
averageScore,
|
|
788
|
+
minimumScore,
|
|
789
|
+
routes,
|
|
790
|
+
issues,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function buildRealtimePanel(
|
|
795
|
+
rawPanel: unknown,
|
|
796
|
+
options: DevtoolsOptions,
|
|
797
|
+
): DevtoolsRealtimePanel {
|
|
798
|
+
const source = toRecord(rawPanel);
|
|
799
|
+
if (!source) {
|
|
800
|
+
throw new Error("panel 'realtime' must be an object");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const connectionState = parseOptionalString(source, ["connectionState", "state"]) ?? "unknown";
|
|
804
|
+
const eventsPerSecond =
|
|
805
|
+
parseOptionalNumber(source, ["eventsPerSecond", "deliveredRps", "rps"]) ?? 0;
|
|
806
|
+
const lagP95Ms = parseOptionalNumber(source, ["lagP95Ms", "p95"]) ?? 0;
|
|
807
|
+
const droppedRatio = parseOptionalNumber(source, ["droppedRatio", "dropRatio"]) ?? 0;
|
|
808
|
+
|
|
809
|
+
const rawSubscriptions = Array.isArray(source.subscriptions) ? source.subscriptions : [];
|
|
810
|
+
const subscriptions: RealtimeSubscriptionSummary[] = rawSubscriptions.map((entry, index) => {
|
|
811
|
+
const subscription = toRecord(entry);
|
|
812
|
+
if (!subscription) {
|
|
813
|
+
throw new Error(`realtime.subscriptions[${index}] must be an object`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const tag = parseRequiredString(
|
|
817
|
+
subscription,
|
|
818
|
+
["tag", "topic"],
|
|
819
|
+
`realtime.subscriptions[${index}].tag`,
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
tag,
|
|
824
|
+
channel: parseOptionalString(subscription, ["channel"]) ?? "-",
|
|
825
|
+
state: parseOptionalString(subscription, ["state"]) ?? "active",
|
|
826
|
+
authScope: parseOptionalString(subscription, ["authScope", "auth", "scope"]) ?? "unscoped",
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const authScopedSubscriptions: Record<string, number> = {};
|
|
831
|
+
for (const subscription of subscriptions) {
|
|
832
|
+
authScopedSubscriptions[subscription.authScope] =
|
|
833
|
+
(authScopedSubscriptions[subscription.authScope] ?? 0) + 1;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const activeSubscriptions = subscriptions.filter((subscription) => subscription.state === "active").length;
|
|
837
|
+
const unscopedSubscriptions = subscriptions.filter(
|
|
838
|
+
(subscription) => subscription.authScope === "unscoped",
|
|
839
|
+
).length;
|
|
840
|
+
|
|
841
|
+
const issues: PanelIssue[] = [];
|
|
842
|
+
if (connectionState !== "connected") {
|
|
843
|
+
issues.push({
|
|
844
|
+
code: "CONNECTION_UNSTABLE",
|
|
845
|
+
message: `connection state is '${connectionState}'`,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (lagP95Ms > options.maxLagP95Ms) {
|
|
849
|
+
issues.push({
|
|
850
|
+
code: "LAG_P95_EXCEEDED",
|
|
851
|
+
message: `lag p95 ${lagP95Ms}ms exceeds ${options.maxLagP95Ms}ms`,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
if (droppedRatio > options.maxDropRatio) {
|
|
855
|
+
issues.push({
|
|
856
|
+
code: "DROP_RATIO_EXCEEDED",
|
|
857
|
+
message: `drop ratio ${droppedRatio.toFixed(4)} exceeds ${options.maxDropRatio.toFixed(4)}`,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (unscopedSubscriptions > 0) {
|
|
861
|
+
issues.push({
|
|
862
|
+
code: "SUBSCRIPTION_AUTH_SCOPE_MISSING",
|
|
863
|
+
message: `${unscopedSubscriptions} subscription(s) missing auth scope`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
name: "realtime",
|
|
869
|
+
status: issues.length > 0 ? "warn" : "ok",
|
|
870
|
+
connectionState,
|
|
871
|
+
eventsPerSecond,
|
|
872
|
+
lagP95Ms,
|
|
873
|
+
droppedRatio,
|
|
874
|
+
activeSubscriptions,
|
|
875
|
+
authScopedSubscriptions,
|
|
876
|
+
subscriptions,
|
|
877
|
+
issues,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function buildAuthPanel(rawPanel: unknown): DevtoolsAuthPanel {
|
|
882
|
+
const source = toRecord(rawPanel);
|
|
883
|
+
if (!source) {
|
|
884
|
+
throw new Error("panel 'auth' must be an object");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const sessionStatus = parseOptionalString(source, ["sessionStatus", "session"]) ?? "missing";
|
|
888
|
+
const routeAuth = parseOptionalString(source, ["routeAuth", "requiredAuth"]) ?? "public";
|
|
889
|
+
const userId = parseOptionalString(source, ["userId", "user"]);
|
|
890
|
+
const role = parseOptionalString(source, ["role"]);
|
|
891
|
+
const permissions = parseStringArray(source.permissions).sort();
|
|
892
|
+
const tokenExpiresInSec = parseOptionalNumber(source, ["tokenExpiresInSec", "ttlSeconds"]);
|
|
893
|
+
|
|
894
|
+
const issues: PanelIssue[] = [];
|
|
895
|
+
const isPublicRoute = routeAuth === "public" || routeAuth === "none";
|
|
896
|
+
if (!isPublicRoute && sessionStatus !== "active") {
|
|
897
|
+
issues.push({
|
|
898
|
+
code: "ROUTE_AUTH_SESSION_MISMATCH",
|
|
899
|
+
message: `route requires '${routeAuth}' auth but session is '${sessionStatus}'`,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
if (sessionStatus === "active" && !role) {
|
|
903
|
+
issues.push({
|
|
904
|
+
code: "ROLE_MISSING",
|
|
905
|
+
message: "active session is missing role visibility",
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
if (tokenExpiresInSec !== undefined && tokenExpiresInSec < 300) {
|
|
909
|
+
issues.push({
|
|
910
|
+
code: "TOKEN_EXPIRING_SOON",
|
|
911
|
+
message: `token expires in ${Math.trunc(tokenExpiresInSec)} seconds`,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
name: "auth",
|
|
917
|
+
status: issues.length > 0 ? "warn" : "ok",
|
|
918
|
+
sessionStatus,
|
|
919
|
+
routeAuth,
|
|
920
|
+
userId,
|
|
921
|
+
role,
|
|
922
|
+
permissions,
|
|
923
|
+
tokenExpiresInSec,
|
|
924
|
+
issues,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function buildPanel(
|
|
929
|
+
panelName: DevtoolsPanelName,
|
|
930
|
+
payload: Record<string, unknown>,
|
|
931
|
+
options: DevtoolsOptions,
|
|
932
|
+
): DevtoolsPanel {
|
|
933
|
+
const rawPanel = payload[panelName];
|
|
934
|
+
if (rawPanel === undefined) {
|
|
935
|
+
throw new Error(`panel '${panelName}' input is missing from payload`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (panelName === "explain") {
|
|
939
|
+
return buildExplainPanel(rawPanel);
|
|
940
|
+
}
|
|
941
|
+
if (panelName === "waterfall") {
|
|
942
|
+
return buildWaterfallPanel(rawPanel, options);
|
|
943
|
+
}
|
|
944
|
+
if (panelName === "performance") {
|
|
945
|
+
return buildPerformancePanel(rawPanel, options);
|
|
946
|
+
}
|
|
947
|
+
if (panelName === "realtime") {
|
|
948
|
+
return buildRealtimePanel(rawPanel, options);
|
|
949
|
+
}
|
|
950
|
+
return buildAuthPanel(rawPanel);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function buildDevtoolsReport(
|
|
954
|
+
options: DevtoolsOptions,
|
|
955
|
+
context: CommandContext,
|
|
956
|
+
): DevtoolsReport | CommandResult {
|
|
957
|
+
const payload = readInputJson(options.inputPath, context);
|
|
958
|
+
if (isCommandResult(payload)) {
|
|
959
|
+
return payload;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const source = toRecord(payload);
|
|
963
|
+
if (!source) {
|
|
964
|
+
return {
|
|
965
|
+
exitCode: 1,
|
|
966
|
+
stderr: "aurora devtools: input payload must be an object",
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const schemaVersion = source.schemaVersion;
|
|
971
|
+
if (schemaVersion !== 1) {
|
|
972
|
+
return {
|
|
973
|
+
exitCode: 1,
|
|
974
|
+
stderr: `aurora devtools: unsupported schemaVersion ${String(schemaVersion)}; expected 1`,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const panels: DevtoolsPanel[] = [];
|
|
979
|
+
try {
|
|
980
|
+
for (const panelName of options.selectedPanels) {
|
|
981
|
+
panels.push(buildPanel(panelName, source, options));
|
|
982
|
+
}
|
|
983
|
+
} catch (error) {
|
|
984
|
+
return {
|
|
985
|
+
exitCode: 1,
|
|
986
|
+
stderr: `aurora devtools: ${error instanceof Error ? error.message : String(error)}`,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const status: PanelStatus = panels.some((panel) => panel.status === "warn") ? "warn" : "ok";
|
|
991
|
+
return {
|
|
992
|
+
mode: "devtools",
|
|
993
|
+
schemaVersion: 1,
|
|
994
|
+
projectRoot: context.cwd,
|
|
995
|
+
inputPath: options.inputPath,
|
|
996
|
+
status,
|
|
997
|
+
selectedPanels: options.selectedPanels,
|
|
998
|
+
panels,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function renderIssues(issues: readonly PanelIssue[]): string[] {
|
|
1003
|
+
if (issues.length === 0) {
|
|
1004
|
+
return ["issues: none"];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const lines = ["issues:"];
|
|
1008
|
+
for (const issue of issues) {
|
|
1009
|
+
lines.push(`- [${issue.code}] ${issue.message}`);
|
|
1010
|
+
}
|
|
1011
|
+
return lines;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function renderPanelText(panel: DevtoolsPanel): string[] {
|
|
1015
|
+
if (panel.name === "explain") {
|
|
1016
|
+
return [
|
|
1017
|
+
"[explain]",
|
|
1018
|
+
`status: ${panel.status}`,
|
|
1019
|
+
`module: ${panel.module}`,
|
|
1020
|
+
`queries: ${panel.queries}`,
|
|
1021
|
+
`actions: ${panel.actions}`,
|
|
1022
|
+
`realtime_queries: ${panel.realtimeQueries}`,
|
|
1023
|
+
`query_auth: ${panel.authCoverage.queryAuth.join(",") || "-"}`,
|
|
1024
|
+
`action_auth: ${panel.authCoverage.actionAuth.join(",") || "-"}`,
|
|
1025
|
+
`missing_query_auth: ${panel.authCoverage.missingQueryAuth}`,
|
|
1026
|
+
`missing_action_auth: ${panel.authCoverage.missingActionAuth}`,
|
|
1027
|
+
`warnings: ${panel.warnings.length}`,
|
|
1028
|
+
...renderIssues(panel.issues),
|
|
1029
|
+
];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (panel.name === "waterfall") {
|
|
1033
|
+
return [
|
|
1034
|
+
"[waterfall]",
|
|
1035
|
+
`status: ${panel.status}`,
|
|
1036
|
+
`request_id: ${panel.requestId ?? "-"}`,
|
|
1037
|
+
`operations: ${panel.operations}`,
|
|
1038
|
+
`total_duration_ms: ${panel.totalDurationMs}`,
|
|
1039
|
+
`sequential_operations: ${panel.sequentialOperations}`,
|
|
1040
|
+
`parallel_operations: ${panel.parallelOperations}`,
|
|
1041
|
+
`blocking_over_threshold: ${panel.blockingOverThreshold}`,
|
|
1042
|
+
`longest_operation: ${panel.longestOperation?.name ?? "-"} ` +
|
|
1043
|
+
`(${panel.longestOperation?.kind ?? "-"}, ${panel.longestOperation?.durationMs ?? 0}ms)`,
|
|
1044
|
+
`phases: ${panel.phases.length}`,
|
|
1045
|
+
...renderIssues(panel.issues),
|
|
1046
|
+
];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (panel.name === "performance") {
|
|
1050
|
+
const lines = [
|
|
1051
|
+
"[performance]",
|
|
1052
|
+
`status: ${panel.status}`,
|
|
1053
|
+
`average_score: ${panel.averageScore}`,
|
|
1054
|
+
`minimum_score: ${panel.minimumScore}`,
|
|
1055
|
+
`routes: ${panel.routes.length}`,
|
|
1056
|
+
"route_scores:",
|
|
1057
|
+
];
|
|
1058
|
+
for (const route of panel.routes) {
|
|
1059
|
+
lines.push(
|
|
1060
|
+
`- ${route.route} score=${route.score} js_bytes=${route.jsBytes} ` +
|
|
1061
|
+
`payload_bytes=${route.payloadBytes} lcp_ms=${route.lcpMs} notes=${route.notes.join("|") || "-"}`,
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
lines.push(...renderIssues(panel.issues));
|
|
1065
|
+
return lines;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (panel.name === "realtime") {
|
|
1069
|
+
const scopeSummary = Object.entries(panel.authScopedSubscriptions)
|
|
1070
|
+
.sort((left, right) => left[0].localeCompare(right[0]))
|
|
1071
|
+
.map(([scope, count]) => `${scope}:${count}`)
|
|
1072
|
+
.join(",");
|
|
1073
|
+
return [
|
|
1074
|
+
"[realtime]",
|
|
1075
|
+
`status: ${panel.status}`,
|
|
1076
|
+
`connection_state: ${panel.connectionState}`,
|
|
1077
|
+
`events_per_second: ${panel.eventsPerSecond.toFixed(2)}`,
|
|
1078
|
+
`lag_p95_ms: ${panel.lagP95Ms}`,
|
|
1079
|
+
`dropped_ratio: ${panel.droppedRatio.toFixed(4)}`,
|
|
1080
|
+
`active_subscriptions: ${panel.activeSubscriptions}`,
|
|
1081
|
+
`auth_scoped_subscriptions: ${scopeSummary || "-"}`,
|
|
1082
|
+
`subscriptions: ${panel.subscriptions.length}`,
|
|
1083
|
+
...renderIssues(panel.issues),
|
|
1084
|
+
];
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return [
|
|
1088
|
+
"[auth]",
|
|
1089
|
+
`status: ${panel.status}`,
|
|
1090
|
+
`session_status: ${panel.sessionStatus}`,
|
|
1091
|
+
`route_auth: ${panel.routeAuth}`,
|
|
1092
|
+
`user_id: ${panel.userId ?? "-"}`,
|
|
1093
|
+
`role: ${panel.role ?? "-"}`,
|
|
1094
|
+
`permissions: ${panel.permissions.join(",") || "-"}`,
|
|
1095
|
+
`token_expires_in_sec: ${panel.tokenExpiresInSec ?? "-"}`,
|
|
1096
|
+
...renderIssues(panel.issues),
|
|
1097
|
+
];
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function renderDevtoolsText(report: DevtoolsReport): string {
|
|
1101
|
+
const lines = [
|
|
1102
|
+
"aurora devtools report",
|
|
1103
|
+
`project_root: ${report.projectRoot}`,
|
|
1104
|
+
`input_path: ${report.inputPath}`,
|
|
1105
|
+
`status: ${report.status}`,
|
|
1106
|
+
`panels: ${report.selectedPanels.join(",")}`,
|
|
1107
|
+
];
|
|
1108
|
+
|
|
1109
|
+
for (const panel of report.panels) {
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
lines.push(...renderPanelText(panel));
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return lines.join("\n");
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export function runDevtoolsCommand(
|
|
1118
|
+
args: ReadonlyArray<string>,
|
|
1119
|
+
context: CommandContext,
|
|
1120
|
+
): CommandResult {
|
|
1121
|
+
const options = parseDevtoolsOptions(args);
|
|
1122
|
+
if (isCommandResult(options)) {
|
|
1123
|
+
return options;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const report = buildDevtoolsReport(options, context);
|
|
1127
|
+
if (isCommandResult(report)) {
|
|
1128
|
+
return report;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const exitCode = report.status === "warn" ? 1 : 0;
|
|
1132
|
+
if (options.format === "json") {
|
|
1133
|
+
return {
|
|
1134
|
+
exitCode,
|
|
1135
|
+
stdout: JSON.stringify(report, null, 2),
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
exitCode,
|
|
1141
|
+
stdout: renderDevtoolsText(report),
|
|
1142
|
+
};
|
|
1143
|
+
}
|