@everystack/cli 0.1.0
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 +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/s3.ts +125 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { updateCommand } from './commands/update.js';
|
|
6
|
+
import { certsCommand } from './commands/certs.js';
|
|
7
|
+
import { channelsCommand } from './commands/channels.js';
|
|
8
|
+
import { branchesCommand } from './commands/branches.js';
|
|
9
|
+
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
|
|
10
|
+
import { consoleCommand } from './commands/console.js';
|
|
11
|
+
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
|
+
import { diagCommand } from './commands/diag.js';
|
|
13
|
+
import { analyzeSSRCommand } from './commands/analyze.js';
|
|
14
|
+
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
15
|
+
import { fail } from './output.js';
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const command = args[0];
|
|
19
|
+
|
|
20
|
+
function parseFlags(args: string[]): Record<string, string> {
|
|
21
|
+
const flags: Record<string, string> = {};
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
// Handle both --flag and -flag
|
|
24
|
+
if (args[i].startsWith('--')) {
|
|
25
|
+
const key = args[i].slice(2);
|
|
26
|
+
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
27
|
+
flags[key] = value;
|
|
28
|
+
} else if (args[i].startsWith('-') && args[i].length > 1) {
|
|
29
|
+
const key = args[i].slice(1);
|
|
30
|
+
const value = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : 'true';
|
|
31
|
+
flags[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return flags;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Auto-detect HOST_URL from SST outputs.
|
|
39
|
+
* SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
|
|
40
|
+
* If HOST_URL isn't already set, use the routerUrl from the last deploy.
|
|
41
|
+
*/
|
|
42
|
+
async function loadSstOutputs(): Promise<void> {
|
|
43
|
+
if (process.env.HOST_URL) return;
|
|
44
|
+
|
|
45
|
+
const outputsPath = path.resolve('.sst', 'outputs.json');
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(outputsPath, 'utf8');
|
|
48
|
+
const outputs = JSON.parse(raw);
|
|
49
|
+
if (outputs.routerUrl) {
|
|
50
|
+
process.env.HOST_URL = outputs.routerUrl;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
if (!command || command === '--help' || command === '-h') {
|
|
59
|
+
printHelp();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const flags = parseFlags(args.slice(1));
|
|
64
|
+
|
|
65
|
+
// Auto-detect deployed URL from SST outputs
|
|
66
|
+
await loadSstOutputs();
|
|
67
|
+
|
|
68
|
+
switch (command) {
|
|
69
|
+
case 'update':
|
|
70
|
+
await updateCommand(flags);
|
|
71
|
+
break;
|
|
72
|
+
case 'certs:generate':
|
|
73
|
+
await certsCommand('generate', flags);
|
|
74
|
+
break;
|
|
75
|
+
case 'certs:configure':
|
|
76
|
+
await certsCommand('configure', flags);
|
|
77
|
+
break;
|
|
78
|
+
case 'channels':
|
|
79
|
+
await channelsCommand(args[1], flags);
|
|
80
|
+
break;
|
|
81
|
+
case 'branches':
|
|
82
|
+
await branchesCommand(args[1], flags);
|
|
83
|
+
break;
|
|
84
|
+
case 'db:migrate':
|
|
85
|
+
await dbMigrateCommand(flags);
|
|
86
|
+
break;
|
|
87
|
+
case 'db:seed':
|
|
88
|
+
await dbSeedCommand(flags);
|
|
89
|
+
break;
|
|
90
|
+
case 'db:reset':
|
|
91
|
+
await dbResetCommand(flags);
|
|
92
|
+
break;
|
|
93
|
+
case 'db:psql':
|
|
94
|
+
await dbPsqlCommand(flags);
|
|
95
|
+
break;
|
|
96
|
+
case 'console':
|
|
97
|
+
await consoleCommand(flags);
|
|
98
|
+
break;
|
|
99
|
+
case 'cache:purge':
|
|
100
|
+
await cachePurgeCommand(flags);
|
|
101
|
+
break;
|
|
102
|
+
case 'diag': {
|
|
103
|
+
// Positional URL: first arg after 'diag' that doesn't start with '--'
|
|
104
|
+
const diagUrl = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
105
|
+
await diagCommand(diagUrl, flags);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'analyze:ssr': {
|
|
109
|
+
const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
110
|
+
await analyzeSSRCommand(positional, flags);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'logs:errors':
|
|
114
|
+
await logsErrorsCommand(flags);
|
|
115
|
+
break;
|
|
116
|
+
case 'logs:tail':
|
|
117
|
+
await logsTailCommand(flags);
|
|
118
|
+
break;
|
|
119
|
+
case 'logs:query':
|
|
120
|
+
await logsQueryCommand(flags);
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
fail(`Unknown command: ${command}`);
|
|
124
|
+
printHelp();
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function printHelp() {
|
|
130
|
+
console.log(`
|
|
131
|
+
everystack - CLI for Expo apps on everystack
|
|
132
|
+
|
|
133
|
+
Usage:
|
|
134
|
+
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>]
|
|
135
|
+
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>]
|
|
136
|
+
everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
|
|
137
|
+
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
138
|
+
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
139
|
+
everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
|
|
140
|
+
everystack console --stage <name> Interactive REPL on deployed Lambda
|
|
141
|
+
everystack certs:generate [--output ./certs]
|
|
142
|
+
everystack certs:configure [--input ./certs] [--keyid main]
|
|
143
|
+
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
|
144
|
+
everystack cache:purge [--stage <name>] --origin api Bust API origin cache (api|media|web)
|
|
145
|
+
everystack cache:purge [--stage <name>] --path "/api/posts" Bust specific path cache
|
|
146
|
+
everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
|
|
147
|
+
everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
|
|
148
|
+
everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
|
|
149
|
+
everystack logs:errors [--stage <name>] [--limit 20] [--level error|fatal] [--source <name>]
|
|
150
|
+
everystack logs:tail [--stage <name>] [--filter <pattern>] [--since 5m]
|
|
151
|
+
everystack logs:query [--stage <name>] [--level <level>] [--source <name>] [--traceId <id>] [--search <text>]
|
|
152
|
+
everystack branches list
|
|
153
|
+
everystack branches create --name <name>
|
|
154
|
+
everystack branches delete --name <name>
|
|
155
|
+
everystack channels list
|
|
156
|
+
everystack channels create --name <name> [--branch <name>]
|
|
157
|
+
everystack channels edit --name <name> --branch <name>
|
|
158
|
+
|
|
159
|
+
Stage resolution:
|
|
160
|
+
--stage <name> Discover AWS resources for the given stage by querying AWS APIs.
|
|
161
|
+
Uses SST naming convention: {app}-{stage}-{Resource}-{hash}.
|
|
162
|
+
Results are cached in .sst/outputs.{stage}.json for 24 hours.
|
|
163
|
+
|
|
164
|
+
Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
|
|
165
|
+
|
|
166
|
+
Auth:
|
|
167
|
+
All commands use AWS IAM credentials (default credential chain).
|
|
168
|
+
Mobile publishes also accept EVERYSTACK_TOKEN env var.
|
|
169
|
+
|
|
170
|
+
Options:
|
|
171
|
+
--help, -h Show this help message
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((err) => {
|
|
176
|
+
fail(err.message || String(err));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured CLI output helpers.
|
|
3
|
+
*
|
|
4
|
+
* No spinner library — step markers work in CI/CD and terminals alike.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function step(msg: string): void {
|
|
8
|
+
console.log(` > ${msg}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function success(msg: string): void {
|
|
12
|
+
console.log(` \u2713 ${msg}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function warn(msg: string): void {
|
|
16
|
+
console.log(` ! ${msg}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fail(msg: string): void {
|
|
20
|
+
console.error(` \u2717 ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function info(msg: string): void {
|
|
24
|
+
console.log(` ${msg}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssr-analyzer — static analysis for SSR code patterns.
|
|
3
|
+
*
|
|
4
|
+
* Scans app code for common SSR anti-patterns:
|
|
5
|
+
* - useEffect that immediately refetches data (silent re-renders)
|
|
6
|
+
* - Date rendering without timeZone: 'UTC'
|
|
7
|
+
* - Number() coercions (symptom of aggregate type mismatches)
|
|
8
|
+
* - Relative time rendering in loader functions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { glob } from 'glob';
|
|
14
|
+
|
|
15
|
+
interface SSRIssue {
|
|
16
|
+
type: 'immediate-refetch' | 'missing-timezone' | 'aggregate-coercion' | 'relative-time-ssr' | 'browser-only-api';
|
|
17
|
+
severity: 'error' | 'warning' | 'info';
|
|
18
|
+
message: string;
|
|
19
|
+
file: string;
|
|
20
|
+
line?: number;
|
|
21
|
+
snippet?: string;
|
|
22
|
+
fix?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SSRAnalysis {
|
|
26
|
+
issues: SSRIssue[];
|
|
27
|
+
stats: {
|
|
28
|
+
totalFiles: number;
|
|
29
|
+
loaderFiles: number;
|
|
30
|
+
immediateRefetches: number;
|
|
31
|
+
missingTimezones: number;
|
|
32
|
+
aggregateCoercions: number;
|
|
33
|
+
relativeTimeInSSR: number;
|
|
34
|
+
browserOnlyAPIs: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Analyze app directory for SSR patterns.
|
|
40
|
+
*/
|
|
41
|
+
export async function analyzeSSRPatterns(appDir: string): Promise<SSRAnalysis> {
|
|
42
|
+
const issues: SSRIssue[] = [];
|
|
43
|
+
const stats = {
|
|
44
|
+
totalFiles: 0,
|
|
45
|
+
loaderFiles: 0,
|
|
46
|
+
immediateRefetches: 0,
|
|
47
|
+
missingTimezones: 0,
|
|
48
|
+
aggregateCoercions: 0,
|
|
49
|
+
relativeTimeInSSR: 0,
|
|
50
|
+
browserOnlyAPIs: 0,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Find all TypeScript/JavaScript files in app directory
|
|
54
|
+
const files = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
55
|
+
cwd: appDir,
|
|
56
|
+
absolute: true,
|
|
57
|
+
ignore: ['**/node_modules/**', '**/.expo/**', '**/dist/**', '**/__tests__/**'],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
stats.totalFiles = files.length;
|
|
61
|
+
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
64
|
+
const relPath = path.relative(appDir, file);
|
|
65
|
+
|
|
66
|
+
// Check if file exports a loader (SSR function)
|
|
67
|
+
const hasLoader = /export\s+(?:async\s+)?function\s+loader\s*\(/.test(content);
|
|
68
|
+
if (hasLoader) {
|
|
69
|
+
stats.loaderFiles++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 1. Detect immediate refetch pattern
|
|
73
|
+
const immediateRefetchIssues = detectImmediateRefetch(content, relPath, hasLoader);
|
|
74
|
+
issues.push(...immediateRefetchIssues);
|
|
75
|
+
stats.immediateRefetches += immediateRefetchIssues.length;
|
|
76
|
+
|
|
77
|
+
// 2. Detect date rendering without timezone
|
|
78
|
+
const timezoneIssues = detectMissingTimezone(content, relPath, hasLoader);
|
|
79
|
+
issues.push(...timezoneIssues);
|
|
80
|
+
stats.missingTimezones += timezoneIssues.length;
|
|
81
|
+
|
|
82
|
+
// 3. Detect Number() coercions (symptom of type issues)
|
|
83
|
+
const coercionIssues = detectAggregateCoercions(content, relPath);
|
|
84
|
+
issues.push(...coercionIssues);
|
|
85
|
+
stats.aggregateCoercions += coercionIssues.length;
|
|
86
|
+
|
|
87
|
+
// 4. Detect relative time rendering in loader functions
|
|
88
|
+
if (hasLoader) {
|
|
89
|
+
const relativeTimeIssues = detectRelativeTimeInSSR(content, relPath);
|
|
90
|
+
issues.push(...relativeTimeIssues);
|
|
91
|
+
stats.relativeTimeInSSR += relativeTimeIssues.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. Detect browser-only API usage without proper hydration guards
|
|
95
|
+
const browserAPIIssues = detectBrowserOnlyAPIs(content, relPath);
|
|
96
|
+
issues.push(...browserAPIIssues);
|
|
97
|
+
stats.browserOnlyAPIs += browserAPIIssues.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { issues, stats };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Detect useEffect that immediately fetches after SSR.
|
|
105
|
+
*/
|
|
106
|
+
function detectImmediateRefetch(content: string, file: string, hasLoader: boolean): SSRIssue[] {
|
|
107
|
+
const issues: SSRIssue[] = [];
|
|
108
|
+
|
|
109
|
+
// Pattern: useEffect(() => { fetchSomething() }, [dependency])
|
|
110
|
+
// This is only a problem if the page has a loader
|
|
111
|
+
if (!hasLoader) return issues;
|
|
112
|
+
|
|
113
|
+
const lines = content.split('\n');
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const line = lines[i];
|
|
116
|
+
|
|
117
|
+
// Look for useEffect with immediate function call
|
|
118
|
+
if (/useEffect\s*\(\s*\(\s*\)\s*=>/.test(line)) {
|
|
119
|
+
// Check if next few lines contain a fetch/api call
|
|
120
|
+
const nextLines = lines.slice(i, Math.min(i + 10, lines.length)).join('\n');
|
|
121
|
+
if (/(?:fetch|api\.from|fetchProfile|fetchPost|fetchData)\s*\(/.test(nextLines)) {
|
|
122
|
+
// Check if there's a comment explaining why (e.g., "for real-time updates")
|
|
123
|
+
const hasExplanation = /\/\/.*(?:real-time|updates|subscribe|listen)/.test(nextLines);
|
|
124
|
+
|
|
125
|
+
issues.push({
|
|
126
|
+
type: 'immediate-refetch',
|
|
127
|
+
severity: hasExplanation ? 'info' : 'warning',
|
|
128
|
+
message: hasExplanation
|
|
129
|
+
? 'useEffect fetches immediately (explained in comment)'
|
|
130
|
+
: 'useEffect fetches immediately after SSR, defeating caching benefit',
|
|
131
|
+
file,
|
|
132
|
+
line: i + 1,
|
|
133
|
+
snippet: line.trim(),
|
|
134
|
+
fix: hasExplanation
|
|
135
|
+
? undefined
|
|
136
|
+
: 'Check if loader data is fresh before refetching, or document why immediate fetch is needed',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Detect date rendering without timeZone: 'UTC'.
|
|
147
|
+
*/
|
|
148
|
+
function detectMissingTimezone(content: string, file: string, hasLoader: boolean): SSRIssue[] {
|
|
149
|
+
const issues: SSRIssue[] = [];
|
|
150
|
+
|
|
151
|
+
// Only flag this for files with loaders (SSR pages)
|
|
152
|
+
if (!hasLoader) return issues;
|
|
153
|
+
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i];
|
|
157
|
+
|
|
158
|
+
// Look for toLocaleDateString or toLocaleTimeString
|
|
159
|
+
if (/\.toLocale(?:Date|Time)String\s*\(/.test(line)) {
|
|
160
|
+
// Check if this call includes timeZone option
|
|
161
|
+
const hasTimezone = /timeZone\s*:\s*['"]UTC['"]/.test(line);
|
|
162
|
+
|
|
163
|
+
if (!hasTimezone) {
|
|
164
|
+
issues.push({
|
|
165
|
+
type: 'missing-timezone',
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
message: 'Date formatting without timeZone: \'UTC\' may cause SSR/client mismatch',
|
|
168
|
+
file,
|
|
169
|
+
line: i + 1,
|
|
170
|
+
snippet: line.trim(),
|
|
171
|
+
fix: 'Add timeZone: \'UTC\' to the options object',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return issues;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Detect Number() coercions on loader data (symptom of aggregate type issue).
|
|
182
|
+
*/
|
|
183
|
+
function detectAggregateCoercions(content: string, file: string): SSRIssue[] {
|
|
184
|
+
const issues: SSRIssue[] = [];
|
|
185
|
+
|
|
186
|
+
const lines = content.split('\n');
|
|
187
|
+
for (let i = 0; i < lines.length; i++) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
|
|
190
|
+
// Look for Number(loaderData.something) or Number(data.count)
|
|
191
|
+
const match = /Number\s*\(\s*(?:loaderData|data)\.(\w+)\s*\)/.exec(line);
|
|
192
|
+
if (match) {
|
|
193
|
+
const field = match[1];
|
|
194
|
+
// Common aggregate fields
|
|
195
|
+
if (/(?:count|total|sum|avg|min|max)/.test(field.toLowerCase())) {
|
|
196
|
+
issues.push({
|
|
197
|
+
type: 'aggregate-coercion',
|
|
198
|
+
severity: 'info',
|
|
199
|
+
message: `Number() coercion on '${field}' suggests SQL aggregate returns string`,
|
|
200
|
+
file,
|
|
201
|
+
line: i + 1,
|
|
202
|
+
snippet: line.trim(),
|
|
203
|
+
fix: 'Consider handling aggregate type coercion at the API/handler level',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return issues;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Detect relative time rendering in loader functions.
|
|
214
|
+
*/
|
|
215
|
+
function detectRelativeTimeInSSR(content: string, file: string): SSRIssue[] {
|
|
216
|
+
const issues: SSRIssue[] = [];
|
|
217
|
+
|
|
218
|
+
// Extract loader function body
|
|
219
|
+
const loaderMatch = /export\s+(?:async\s+)?function\s+loader\s*\([^)]*\)\s*[^{]*\{([\s\S]+?)\n\}/.exec(content);
|
|
220
|
+
if (!loaderMatch) return issues;
|
|
221
|
+
|
|
222
|
+
const loaderBody = loaderMatch[1];
|
|
223
|
+
const lines = loaderBody.split('\n');
|
|
224
|
+
|
|
225
|
+
// Look for patterns like "ago", "just now", "minutes", etc. in return statements
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const line = lines[i];
|
|
228
|
+
|
|
229
|
+
if (/(?:ago|just now|moment ago|time(?:Ago|Since))/.test(line)) {
|
|
230
|
+
// Find line number in full file
|
|
231
|
+
const fullLines = content.split('\n');
|
|
232
|
+
const lineIndex = fullLines.findIndex((l, idx) =>
|
|
233
|
+
idx > content.indexOf('function loader') &&
|
|
234
|
+
l.includes(line.trim())
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
issues.push({
|
|
238
|
+
type: 'relative-time-ssr',
|
|
239
|
+
severity: 'error',
|
|
240
|
+
message: 'Relative time rendering detected in loader (will be stale in CDN cache)',
|
|
241
|
+
file,
|
|
242
|
+
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
|
|
243
|
+
snippet: line.trim(),
|
|
244
|
+
fix: 'Return absolute timestamp from loader, format as relative time on client only',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return issues;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Detect browser-only API usage without proper hydration guards.
|
|
254
|
+
*
|
|
255
|
+
* Patterns that cause hydration mismatches:
|
|
256
|
+
* - typeof document/window checks that return different results on SSR vs client
|
|
257
|
+
* - ReactDOM.createPortal without useEffect guard
|
|
258
|
+
* - Direct window/document access without guards
|
|
259
|
+
*/
|
|
260
|
+
function detectBrowserOnlyAPIs(content: string, file: string): SSRIssue[] {
|
|
261
|
+
const issues: SSRIssue[] = [];
|
|
262
|
+
const lines = content.split('\n');
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < lines.length; i++) {
|
|
265
|
+
const line = lines[i];
|
|
266
|
+
|
|
267
|
+
// Pattern 1: typeof document/window checks that return early
|
|
268
|
+
// Example: if (typeof document === 'undefined') return null;
|
|
269
|
+
if (/(?:typeof\s+(?:document|window)\s*[!=]=\s*['"]undefined['"]|typeof\s+(?:document|window)\s*===\s*['"]undefined['"])/.test(line)) {
|
|
270
|
+
// Check if this is inside a component (has 'return' in surrounding context)
|
|
271
|
+
const prevLines = lines.slice(Math.max(0, i - 10), i).join('\n');
|
|
272
|
+
const nextLines = lines.slice(i, Math.min(i + 10, lines.length)).join('\n');
|
|
273
|
+
const context = prevLines + '\n' + line + '\n' + nextLines;
|
|
274
|
+
|
|
275
|
+
// Check if this is a component render function (has JSX return or ReactDOM)
|
|
276
|
+
const isInRenderPath = /(?:return\s+(?:<|ReactDOM|null)|const\s+\w+\s*=\s*\(\s*\)\s*=>)/.test(context);
|
|
277
|
+
|
|
278
|
+
if (isInRenderPath) {
|
|
279
|
+
// Check if there's a useEffect or useState to defer rendering
|
|
280
|
+
const hasHydrationGuard = /(?:useEffect|useState|useMounted)\s*\(/.test(prevLines);
|
|
281
|
+
|
|
282
|
+
if (!hasHydrationGuard) {
|
|
283
|
+
issues.push({
|
|
284
|
+
type: 'browser-only-api',
|
|
285
|
+
severity: 'error',
|
|
286
|
+
message: 'Browser API check without hydration guard causes SSR mismatch',
|
|
287
|
+
file,
|
|
288
|
+
line: i + 1,
|
|
289
|
+
snippet: line.trim(),
|
|
290
|
+
fix: 'Use useState + useEffect to defer rendering until after hydration. See: https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Pattern 2: ReactDOM.createPortal without useEffect guard
|
|
297
|
+
if (/ReactDOM\.createPortal\s*\(/.test(line)) {
|
|
298
|
+
const prevLines = lines.slice(Math.max(0, i - 15), i).join('\n');
|
|
299
|
+
const hasHydrationGuard = /(?:useEffect|useState.*setMounted|useMounted)\s*\(/.test(prevLines);
|
|
300
|
+
const hasEarlyReturn = /if\s*\([^)]*\)\s*return\s+null/.test(prevLines);
|
|
301
|
+
|
|
302
|
+
if (!hasHydrationGuard || !hasEarlyReturn) {
|
|
303
|
+
issues.push({
|
|
304
|
+
type: 'browser-only-api',
|
|
305
|
+
severity: 'error',
|
|
306
|
+
message: 'ReactDOM.createPortal without hydration guard causes SSR mismatch',
|
|
307
|
+
file,
|
|
308
|
+
line: i + 1,
|
|
309
|
+
snippet: line.trim(),
|
|
310
|
+
fix: 'Use useState + useEffect to defer portal rendering until after hydration',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Pattern 3: Direct window/document access (without typeof guard)
|
|
316
|
+
// But exclude common safe patterns like typeof window !== 'undefined'
|
|
317
|
+
const directAccessMatch = /(?:^|[^a-zA-Z_])(?:window|document)\./.exec(line);
|
|
318
|
+
if (directAccessMatch && !/typeof\s+(?:window|document)/.test(line)) {
|
|
319
|
+
// Check if this line is inside a useEffect or browser-only conditional
|
|
320
|
+
const prevLines = lines.slice(Math.max(0, i - 5), i).join('\n');
|
|
321
|
+
const isGuarded = /(?:useEffect|if\s*\(\s*(?:typeof\s+)?(?:window|document)|Platform\.OS\s*===\s*['"]web['"])/.test(prevLines);
|
|
322
|
+
|
|
323
|
+
if (!isGuarded && !line.trim().startsWith('//')) {
|
|
324
|
+
issues.push({
|
|
325
|
+
type: 'browser-only-api',
|
|
326
|
+
severity: 'warning',
|
|
327
|
+
message: 'Direct browser API access may fail during SSR',
|
|
328
|
+
file,
|
|
329
|
+
line: i + 1,
|
|
330
|
+
snippet: line.trim(),
|
|
331
|
+
fix: 'Guard with typeof check or move into useEffect',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return issues;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Generate a report from SSR analysis.
|
|
342
|
+
*/
|
|
343
|
+
export function generateSSRReport(analysis: SSRAnalysis): string {
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
|
|
346
|
+
lines.push(' SSR Code Analysis');
|
|
347
|
+
lines.push(' ' + '='.repeat(58));
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push(` Files scanned: ${analysis.stats.totalFiles}`);
|
|
350
|
+
lines.push(` SSR pages (loader): ${analysis.stats.loaderFiles}`);
|
|
351
|
+
lines.push('');
|
|
352
|
+
|
|
353
|
+
if (analysis.issues.length === 0) {
|
|
354
|
+
lines.push(' ✓ No SSR anti-patterns detected');
|
|
355
|
+
return lines.join('\n');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Group issues by type
|
|
359
|
+
const grouped = {
|
|
360
|
+
'browser-only-api': analysis.issues.filter(i => i.type === 'browser-only-api'),
|
|
361
|
+
'immediate-refetch': analysis.issues.filter(i => i.type === 'immediate-refetch'),
|
|
362
|
+
'missing-timezone': analysis.issues.filter(i => i.type === 'missing-timezone'),
|
|
363
|
+
'aggregate-coercion': analysis.issues.filter(i => i.type === 'aggregate-coercion'),
|
|
364
|
+
'relative-time-ssr': analysis.issues.filter(i => i.type === 'relative-time-ssr'),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (grouped['browser-only-api'].length > 0) {
|
|
368
|
+
lines.push(` ✗ Browser-Only API Mismatches (${grouped['browser-only-api'].length}):`);
|
|
369
|
+
lines.push(' Components access browser APIs without hydration guards (causes React error #418)');
|
|
370
|
+
lines.push('');
|
|
371
|
+
for (const issue of grouped['browser-only-api']) {
|
|
372
|
+
lines.push(` ${issue.file}:${issue.line}`);
|
|
373
|
+
if (issue.snippet) {
|
|
374
|
+
lines.push(` ${issue.snippet.substring(0, 70)}...`);
|
|
375
|
+
}
|
|
376
|
+
if (issue.fix) {
|
|
377
|
+
lines.push(` Fix: ${issue.fix.substring(0, 70)}...`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
lines.push('');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (grouped['immediate-refetch'].length > 0) {
|
|
384
|
+
lines.push(` ⚠ Immediate Re-fetches (${grouped['immediate-refetch'].length}):`);
|
|
385
|
+
lines.push(' Pages that fetch immediately after SSR, defeating cache benefits');
|
|
386
|
+
lines.push('');
|
|
387
|
+
for (const issue of grouped['immediate-refetch'].slice(0, 5)) {
|
|
388
|
+
lines.push(` ${issue.file}:${issue.line}`);
|
|
389
|
+
if (issue.snippet) {
|
|
390
|
+
lines.push(` ${issue.snippet.substring(0, 60)}...`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (grouped['immediate-refetch'].length > 5) {
|
|
394
|
+
lines.push(` ... and ${grouped['immediate-refetch'].length - 5} more`);
|
|
395
|
+
}
|
|
396
|
+
lines.push('');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (grouped['missing-timezone'].length > 0) {
|
|
400
|
+
lines.push(` ⚠ Missing Timezone (${grouped['missing-timezone'].length}):`);
|
|
401
|
+
lines.push(' Date formatting without timeZone: \'UTC\' may cause hydration mismatches');
|
|
402
|
+
lines.push('');
|
|
403
|
+
for (const issue of grouped['missing-timezone'].slice(0, 5)) {
|
|
404
|
+
lines.push(` ${issue.file}:${issue.line}`);
|
|
405
|
+
if (issue.snippet) {
|
|
406
|
+
lines.push(` ${issue.snippet.substring(0, 60)}...`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (grouped['missing-timezone'].length > 5) {
|
|
410
|
+
lines.push(` ... and ${grouped['missing-timezone'].length - 5} more`);
|
|
411
|
+
}
|
|
412
|
+
lines.push('');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (grouped['aggregate-coercion'].length > 0) {
|
|
416
|
+
lines.push(` ℹ Aggregate Coercions (${grouped['aggregate-coercion'].length}):`);
|
|
417
|
+
lines.push(' Number() coercions suggest SQL aggregates return strings');
|
|
418
|
+
lines.push('');
|
|
419
|
+
for (const issue of grouped['aggregate-coercion'].slice(0, 3)) {
|
|
420
|
+
lines.push(` ${issue.file}:${issue.line}`);
|
|
421
|
+
if (issue.snippet) {
|
|
422
|
+
lines.push(` ${issue.snippet.substring(0, 60)}...`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (grouped['aggregate-coercion'].length > 3) {
|
|
426
|
+
lines.push(` ... and ${grouped['aggregate-coercion'].length - 3} more`);
|
|
427
|
+
}
|
|
428
|
+
lines.push('');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (grouped['relative-time-ssr'].length > 0) {
|
|
432
|
+
lines.push(` ✗ Relative Time in SSR (${grouped['relative-time-ssr'].length}):`);
|
|
433
|
+
lines.push(' Loader functions render relative time (will be stale in cache)');
|
|
434
|
+
lines.push('');
|
|
435
|
+
for (const issue of grouped['relative-time-ssr']) {
|
|
436
|
+
lines.push(` ${issue.file}:${issue.line || '?'}`);
|
|
437
|
+
if (issue.snippet) {
|
|
438
|
+
lines.push(` ${issue.snippet.substring(0, 60)}...`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
lines.push('');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return lines.join('\n');
|
|
445
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ASCII table formatter for REPL output.
|
|
3
|
+
* Zero dependencies. Renders arrays of flat objects as bordered tables,
|
|
4
|
+
* falls back to pretty-printed JSON for everything else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function formatResult(value: unknown): string {
|
|
8
|
+
if (value === undefined || value === null) return 'null';
|
|
9
|
+
if (Array.isArray(value) && value.length === 0) return '[]';
|
|
10
|
+
if (Array.isArray(value) && isFlat(value[0])) {
|
|
11
|
+
return formatTable(value as Record<string, unknown>[]);
|
|
12
|
+
}
|
|
13
|
+
return JSON.stringify(value, null, 2);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isFlat(obj: unknown): obj is Record<string, unknown> {
|
|
17
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false;
|
|
18
|
+
return Object.values(obj).every(v => v === null || typeof v !== 'object');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTable(rows: Record<string, unknown>[]): string {
|
|
22
|
+
const keys = Object.keys(rows[0]);
|
|
23
|
+
const widths = keys.map(k =>
|
|
24
|
+
Math.max(k.length, ...rows.map(r => String(r[k] ?? 'null').length))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const top = '┌' + widths.map(w => '─'.repeat(w + 2)).join('┬') + '┐';
|
|
28
|
+
const sep = '├' + widths.map(w => '─'.repeat(w + 2)).join('┼') + '┤';
|
|
29
|
+
const bot = '└' + widths.map(w => '─'.repeat(w + 2)).join('┴') + '┘';
|
|
30
|
+
const row = (cells: string[]) =>
|
|
31
|
+
'│' + cells.map((c, i) => ' ' + c.padEnd(widths[i]) + ' ').join('│') + '│';
|
|
32
|
+
|
|
33
|
+
const lines = [top, row(keys), sep];
|
|
34
|
+
for (const r of rows) {
|
|
35
|
+
lines.push(row(keys.map(k => String(r[k] ?? 'null'))));
|
|
36
|
+
}
|
|
37
|
+
lines.push(bot);
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|