@usequark/quark-create-app 1.23.11
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 +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-config - Configuration Loader
|
|
3
|
+
* Centralised configuration management that combines environment validation,
|
|
4
|
+
* environment-specific defaults, and user overrides into a single config object.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { loadConfig } from "@techstream/quark-config";
|
|
8
|
+
* const config = loadConfig(); // validates env + returns typed config
|
|
9
|
+
* const config = loadConfig({ cache: { defaultTtl: 120 } }); // with overrides
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getAllowedOrigins, getAppUrl } from "./app-url.js";
|
|
13
|
+
import { getEnvironmentConfig, mergeConfig } from "./environment.js";
|
|
14
|
+
import { validateEnv } from "./validate-env.js";
|
|
15
|
+
|
|
16
|
+
/** @type {import("./environment.js").EnvironmentConfig | null} */
|
|
17
|
+
let cachedConfig = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Loads the full application configuration.
|
|
21
|
+
*
|
|
22
|
+
* 1. Validates all required environment variables (throws on failure).
|
|
23
|
+
* 2. Resolves the current environment (dev/test/staging/production).
|
|
24
|
+
* 3. Merges environment-specific defaults with any user overrides.
|
|
25
|
+
* 4. Enriches with computed values (APP_URL, allowed origins, ports).
|
|
26
|
+
* 5. Caches the result - subsequent calls return the same object.
|
|
27
|
+
*
|
|
28
|
+
* @param {Record<string, unknown>} [overrides] - Optional partial overrides
|
|
29
|
+
* @param {Object} [options]
|
|
30
|
+
* @param {boolean} [options.fresh=false] - Force re-computation (bypass cache)
|
|
31
|
+
* @returns {import("./environment.js").EnvironmentConfig & { appUrl: string, allowedOrigins: string[], validated: Record<string, string> }}
|
|
32
|
+
*/
|
|
33
|
+
export function loadConfig(overrides = {}, options = {}) {
|
|
34
|
+
if (cachedConfig && !options.fresh) {
|
|
35
|
+
return cachedConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Step 1: Validate environment variables
|
|
39
|
+
const { validated } = validateEnv();
|
|
40
|
+
|
|
41
|
+
// Step 2: Resolve environment and get defaults
|
|
42
|
+
const envConfig = getEnvironmentConfig();
|
|
43
|
+
|
|
44
|
+
// Step 3: Apply env-var driven overrides
|
|
45
|
+
const envOverrides = buildEnvOverrides(envConfig);
|
|
46
|
+
|
|
47
|
+
// Step 4: Merge: env defaults → env-var overrides → user overrides
|
|
48
|
+
let config = mergeConfig(envConfig, envOverrides);
|
|
49
|
+
config = mergeConfig(config, overrides);
|
|
50
|
+
|
|
51
|
+
// Step 5: Attach computed values
|
|
52
|
+
config.appUrl = getAppUrl();
|
|
53
|
+
config.allowedOrigins = getAllowedOrigins();
|
|
54
|
+
config.validated = validated;
|
|
55
|
+
|
|
56
|
+
cachedConfig = config;
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clears the cached configuration. Useful in tests.
|
|
62
|
+
*/
|
|
63
|
+
export function resetConfig() {
|
|
64
|
+
cachedConfig = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the cached configuration if already loaded, otherwise null.
|
|
69
|
+
* Does NOT trigger validation - use `loadConfig()` for that.
|
|
70
|
+
* @returns {ReturnType<typeof loadConfig> | null}
|
|
71
|
+
*/
|
|
72
|
+
export function getConfig() {
|
|
73
|
+
return cachedConfig;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads specific environment variables and converts them to config overrides.
|
|
78
|
+
* This allows env vars to take precedence over environment defaults.
|
|
79
|
+
*
|
|
80
|
+
* @param {import("./environment.js").EnvironmentConfig} envConfig
|
|
81
|
+
* @returns {Record<string, unknown>}
|
|
82
|
+
*/
|
|
83
|
+
function buildEnvOverrides(_envConfig) {
|
|
84
|
+
const overrides = {};
|
|
85
|
+
|
|
86
|
+
// Server
|
|
87
|
+
if (process.env.PORT) {
|
|
88
|
+
const port = Number.parseInt(process.env.PORT, 10);
|
|
89
|
+
if (!Number.isNaN(port)) overrides.server = { port };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rate limiting
|
|
93
|
+
if (process.env.RATE_LIMIT_MAX) {
|
|
94
|
+
const maxRequests = Number.parseInt(process.env.RATE_LIMIT_MAX, 10);
|
|
95
|
+
if (!Number.isNaN(maxRequests)) {
|
|
96
|
+
overrides.rateLimit = { ...overrides.rateLimit, maxRequests };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (process.env.RATE_LIMIT_WINDOW_MS) {
|
|
100
|
+
const windowMs = Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10);
|
|
101
|
+
if (!Number.isNaN(windowMs)) {
|
|
102
|
+
overrides.rateLimit = { ...overrides.rateLimit, windowMs };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cache
|
|
107
|
+
if (process.env.CACHE_TTL) {
|
|
108
|
+
const defaultTtl = Number.parseInt(process.env.CACHE_TTL, 10);
|
|
109
|
+
if (!Number.isNaN(defaultTtl)) overrides.cache = { defaultTtl };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Logging
|
|
113
|
+
if (process.env.LOG_LEVEL) {
|
|
114
|
+
overrides.logging = { ...overrides.logging, level: process.env.LOG_LEVEL };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Authentication
|
|
118
|
+
if (process.env.AUTH_ALLOW_SIGNUP) {
|
|
119
|
+
const allowSignup = parseBooleanEnv(process.env.AUTH_ALLOW_SIGNUP);
|
|
120
|
+
if (allowSignup !== undefined) {
|
|
121
|
+
overrides.auth = { ...overrides.auth, allowSignup };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Database pool
|
|
126
|
+
if (process.env.DB_POOL_MAX) {
|
|
127
|
+
const poolMax = Number.parseInt(process.env.DB_POOL_MAX, 10);
|
|
128
|
+
if (!Number.isNaN(poolMax)) {
|
|
129
|
+
overrides.db = { ...overrides.db, poolMax };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (process.env.DB_POOL_IDLE_TIMEOUT) {
|
|
133
|
+
const poolIdleTimeout = Number.parseInt(
|
|
134
|
+
process.env.DB_POOL_IDLE_TIMEOUT,
|
|
135
|
+
10,
|
|
136
|
+
);
|
|
137
|
+
if (!Number.isNaN(poolIdleTimeout)) {
|
|
138
|
+
overrides.db = { ...overrides.db, poolIdleTimeout };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return overrides;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseBooleanEnv(value) {
|
|
146
|
+
const normalized = value.trim().toLowerCase();
|
|
147
|
+
|
|
148
|
+
if (["true", "1", "yes", "on"].includes(normalized)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (["false", "0", "no", "off"].includes(normalized)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter using Redis sorted sets.
|
|
3
|
+
* Provides distributed rate limiting across server instances.
|
|
4
|
+
* Falls back gracefully if Redis is unavailable (fail-open).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getSharedRedisClient } from "./redis.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sliding window rate limiter using Redis sorted sets.
|
|
11
|
+
* Each request is scored by its timestamp, and expired entries are pruned on each call.
|
|
12
|
+
* TTL on the key ensures automatic cleanup of old data.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} key - Unique key for the rate limit (e.g., `chat:userId`)
|
|
15
|
+
* @param {number} limit - Max requests per window
|
|
16
|
+
* @param {number} windowMs - Window size in milliseconds (default: 60000 = 1 min)
|
|
17
|
+
* @returns {Promise<{ allowed: boolean, remaining: number, reset: number }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function rateLimit(key, limit = 10, windowMs = 60000) {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const windowStart = now - windowMs;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const client = await getSharedRedisClient();
|
|
25
|
+
if (!client) {
|
|
26
|
+
// Fail open - Redis unavailable
|
|
27
|
+
return {
|
|
28
|
+
allowed: true,
|
|
29
|
+
remaining: limit,
|
|
30
|
+
reset: Math.ceil((now + windowMs) / 1000),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const redisKey = `ratelimit:${key}`;
|
|
35
|
+
|
|
36
|
+
// Pipeline: remove expired entries, count, optionally add request
|
|
37
|
+
// We use a Lua script for atomicity
|
|
38
|
+
const luaScript = `
|
|
39
|
+
local key = KEYS[1]
|
|
40
|
+
local window_start = tonumber(ARGV[1])
|
|
41
|
+
local now = tonumber(ARGV[2])
|
|
42
|
+
local limit = tonumber(ARGV[3])
|
|
43
|
+
local window_ms = tonumber(ARGV[4])
|
|
44
|
+
local window_end = now + window_ms
|
|
45
|
+
|
|
46
|
+
-- Remove entries outside the window
|
|
47
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
|
|
48
|
+
|
|
49
|
+
-- Count remaining entries in window
|
|
50
|
+
local count = redis.call('ZCARD', key)
|
|
51
|
+
|
|
52
|
+
if count >= limit then
|
|
53
|
+
-- Rate limited - return oldest entry for reset calculation
|
|
54
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
55
|
+
local reset_time = window_end
|
|
56
|
+
if #oldest >= 2 then
|
|
57
|
+
reset_time = tonumber(oldest[2]) + window_ms
|
|
58
|
+
end
|
|
59
|
+
return {0, 0, reset_time}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Add current request (score = timestamp, member = unique)
|
|
63
|
+
local member = now .. ':' .. math.random(1000000)
|
|
64
|
+
redis.call('ZADD', key, now, member)
|
|
65
|
+
|
|
66
|
+
-- Set TTL on the key for automatic cleanup
|
|
67
|
+
redis.call('PEXPIRE', key, window_ms + 1000)
|
|
68
|
+
|
|
69
|
+
local remaining = limit - count - 1
|
|
70
|
+
local reset_time = window_end
|
|
71
|
+
|
|
72
|
+
return {1, remaining, reset_time}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
const [allowed, remaining, reset] = await client.eval(
|
|
76
|
+
luaScript,
|
|
77
|
+
1,
|
|
78
|
+
redisKey,
|
|
79
|
+
windowStart,
|
|
80
|
+
now,
|
|
81
|
+
limit,
|
|
82
|
+
windowMs,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
allowed: allowed === 1,
|
|
87
|
+
remaining,
|
|
88
|
+
reset: Math.ceil(reset / 1000),
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
// Fail open - Redis error
|
|
92
|
+
return {
|
|
93
|
+
allowed: true,
|
|
94
|
+
remaining: limit,
|
|
95
|
+
reset: Math.ceil((now + windowMs) / 1000),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply rate limiting to a Next.js API route request.
|
|
102
|
+
* Returns a 429 Response if rate limited, or null if the request is allowed.
|
|
103
|
+
* Includes rate limit headers in the returned response.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} identifier - Unique identifier (userId or IP)
|
|
106
|
+
* @param {object} options - { limit, windowMs, route }
|
|
107
|
+
* @returns {Promise<Response | null>}
|
|
108
|
+
*/
|
|
109
|
+
export async function applyRateLimit(identifier, options = {}) {
|
|
110
|
+
const { limit = 10, windowMs = 60000, route = "default" } = options;
|
|
111
|
+
const key = `${route}:${identifier}`;
|
|
112
|
+
|
|
113
|
+
const result = await rateLimit(key, limit, windowMs);
|
|
114
|
+
|
|
115
|
+
if (!result.allowed) {
|
|
116
|
+
const retryAfter = Math.max(0, result.reset - Math.ceil(Date.now() / 1000));
|
|
117
|
+
return new Response(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
error: "Rate limit exceeded. Please try again later.",
|
|
120
|
+
retryAfter,
|
|
121
|
+
}),
|
|
122
|
+
{
|
|
123
|
+
status: 429,
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
"X-RateLimit-Limit": String(limit),
|
|
127
|
+
"X-RateLimit-Remaining": "0",
|
|
128
|
+
"X-RateLimit-Reset": String(result.reset),
|
|
129
|
+
"Retry-After": String(retryAfter),
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { resolveRedisConnection } from "@techstream/quark-core";
|
|
2
|
+
|
|
3
|
+
let _redis = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get a shared Redis client. Creates and connects on first call, reuses thereafter.
|
|
7
|
+
* Returns null if Redis is unavailable (fail-open).
|
|
8
|
+
*
|
|
9
|
+
* @returns {Promise<import("ioredis").default | null>}
|
|
10
|
+
*/
|
|
11
|
+
export async function getSharedRedisClient() {
|
|
12
|
+
if (_redis) return _redis;
|
|
13
|
+
try {
|
|
14
|
+
const conn = resolveRedisConnection();
|
|
15
|
+
const { default: Redis } = await import("ioredis");
|
|
16
|
+
_redis = new Redis({
|
|
17
|
+
...conn,
|
|
18
|
+
lazyConnect: true,
|
|
19
|
+
maxRetriesPerRequest: 0,
|
|
20
|
+
enableReadyCheck: false,
|
|
21
|
+
});
|
|
22
|
+
_redis.on("error", () => {}); // Suppress errors
|
|
23
|
+
await _redis.connect();
|
|
24
|
+
return _redis;
|
|
25
|
+
} catch {
|
|
26
|
+
await closeSharedRedisClient();
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Close the shared Redis client (for graceful shutdown).
|
|
33
|
+
*/
|
|
34
|
+
export async function closeSharedRedisClient() {
|
|
35
|
+
if (_redis) {
|
|
36
|
+
try {
|
|
37
|
+
await _redis.disconnect();
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore disconnect errors
|
|
40
|
+
}
|
|
41
|
+
_redis = null;
|
|
42
|
+
}
|
|
43
|
+
}
|