@pyreon/zero 0.24.5 → 0.24.6
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/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/og-image.ts
DELETED
|
@@ -1,378 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OG Image generation plugin.
|
|
3
|
-
*
|
|
4
|
-
* Generates Open Graph images at build time from templates with
|
|
5
|
-
* text overlays. Supports locale-specific text for i18n apps.
|
|
6
|
-
* Uses sharp for image processing (same optional dep as favicon/image plugins).
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* // vite.config.ts
|
|
11
|
-
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
12
|
-
*
|
|
13
|
-
* export default {
|
|
14
|
-
* plugins: [
|
|
15
|
-
* ogImagePlugin({
|
|
16
|
-
* locales: ["en", "de", "cs"],
|
|
17
|
-
* templates: [{
|
|
18
|
-
* name: "default",
|
|
19
|
-
* background: "./src/assets/og-bg.jpg",
|
|
20
|
-
* layers: [{
|
|
21
|
-
* text: { en: "Build faster", de: "Schneller bauen", cs: "Stavte rychleji" },
|
|
22
|
-
* y: "40%",
|
|
23
|
-
* fontSize: 72,
|
|
24
|
-
* }],
|
|
25
|
-
* }],
|
|
26
|
-
* }),
|
|
27
|
-
* ],
|
|
28
|
-
* }
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
import { existsSync } from 'node:fs'
|
|
32
|
-
import { join } from 'node:path'
|
|
33
|
-
import type { Plugin } from 'vite'
|
|
34
|
-
|
|
35
|
-
let sharpWarned = false
|
|
36
|
-
function warnSharpMissing() {
|
|
37
|
-
if (sharpWarned) return
|
|
38
|
-
sharpWarned = true
|
|
39
|
-
// oxlint-disable-next-line no-console
|
|
40
|
-
console.warn(
|
|
41
|
-
'\n[Pyreon] sharp not installed — OG images will not be generated. Install for full support: bun add -D sharp\n',
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
export interface OgImageLayer {
|
|
48
|
-
/**
|
|
49
|
-
* Text content. Can be:
|
|
50
|
-
* - A string (same for all locales)
|
|
51
|
-
* - A record mapping locale → text
|
|
52
|
-
* - A function receiving locale and returning text
|
|
53
|
-
*/
|
|
54
|
-
text: string | Record<string, string> | ((locale: string) => string)
|
|
55
|
-
/** X position — number (px) or string with % (e.g. "50%"). Default: "50%" */
|
|
56
|
-
x?: number | string
|
|
57
|
-
/** Y position — number (px) or string with % (e.g. "40%"). Default: "50%" */
|
|
58
|
-
y?: number | string
|
|
59
|
-
/** Font size in px. Default: 64 */
|
|
60
|
-
fontSize?: number
|
|
61
|
-
/** Font family. Default: "sans-serif" */
|
|
62
|
-
fontFamily?: string
|
|
63
|
-
/** Font weight. Default: "bold" */
|
|
64
|
-
fontWeight?: string
|
|
65
|
-
/** Text color. Default: "#ffffff" */
|
|
66
|
-
color?: string
|
|
67
|
-
/** Text anchor (alignment). Default: "middle" */
|
|
68
|
-
textAnchor?: 'start' | 'middle' | 'end'
|
|
69
|
-
/** Max width in px before wrapping. Default: 80% of image width. */
|
|
70
|
-
maxWidth?: number
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface OgImageTemplate {
|
|
74
|
-
/** Template name — used for output file naming. */
|
|
75
|
-
name: string
|
|
76
|
-
/**
|
|
77
|
-
* Background: path to an image file, or a solid color config.
|
|
78
|
-
*
|
|
79
|
-
* @example "./src/assets/og-bg.jpg"
|
|
80
|
-
* @example { color: "#0066ff", width: 1200, height: 630 }
|
|
81
|
-
*/
|
|
82
|
-
background: string | { color: string; width?: number; height?: number }
|
|
83
|
-
/** Output width. Default: 1200 */
|
|
84
|
-
width?: number
|
|
85
|
-
/** Output height. Default: 630 */
|
|
86
|
-
height?: number
|
|
87
|
-
/** Output format. Default: "png" */
|
|
88
|
-
format?: 'png' | 'jpeg'
|
|
89
|
-
/** JPEG quality (1-100). Default: 90 */
|
|
90
|
-
quality?: number
|
|
91
|
-
/** Text layers to overlay on the background. */
|
|
92
|
-
layers?: OgImageLayer[]
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface OgImagePluginConfig {
|
|
96
|
-
/** Templates to generate. */
|
|
97
|
-
templates: OgImageTemplate[]
|
|
98
|
-
/** Locales to generate for. When omitted, generates a single image per template. */
|
|
99
|
-
locales?: string[]
|
|
100
|
-
/** Output directory prefix. Default: "og" */
|
|
101
|
-
outDir?: string
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
function resolvePosition(value: number | string | undefined, dimension: number, fallback = '50%'): number {
|
|
107
|
-
if (value === undefined) value = fallback
|
|
108
|
-
if (typeof value === 'number') return value
|
|
109
|
-
if (value.endsWith('%')) return Math.round((Number.parseFloat(value) / 100) * dimension)
|
|
110
|
-
return Number.parseInt(value, 10) || 0
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function resolveLayerText(layer: OgImageLayer, locale: string): string {
|
|
114
|
-
if (typeof layer.text === 'string') return layer.text
|
|
115
|
-
if (typeof layer.text === 'function') return layer.text(locale)
|
|
116
|
-
return layer.text[locale] ?? layer.text[Object.keys(layer.text)[0] ?? ''] ?? ''
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function escapeXml(str: string): string {
|
|
120
|
-
return str
|
|
121
|
-
.replace(/&/g, '&')
|
|
122
|
-
.replace(/</g, '<')
|
|
123
|
-
.replace(/>/g, '>')
|
|
124
|
-
.replace(/"/g, '"')
|
|
125
|
-
.replace(/'/g, ''')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Build an SVG overlay with text layers.
|
|
130
|
-
* @internal Exported for testing.
|
|
131
|
-
*/
|
|
132
|
-
export function buildTextOverlaySvg(
|
|
133
|
-
layers: OgImageLayer[],
|
|
134
|
-
width: number,
|
|
135
|
-
height: number,
|
|
136
|
-
locale: string,
|
|
137
|
-
): string {
|
|
138
|
-
const textElements = layers.map((layer) => {
|
|
139
|
-
const text = resolveLayerText(layer, locale)
|
|
140
|
-
const x = resolvePosition(layer.x, width, '50%')
|
|
141
|
-
const y = resolvePosition(layer.y, height, '50%')
|
|
142
|
-
const fontSize = layer.fontSize ?? 64
|
|
143
|
-
const fontFamily = layer.fontFamily ?? 'sans-serif'
|
|
144
|
-
const fontWeight = layer.fontWeight ?? 'bold'
|
|
145
|
-
const color = layer.color ?? '#ffffff'
|
|
146
|
-
const anchor = layer.textAnchor ?? 'middle'
|
|
147
|
-
const maxWidth = layer.maxWidth ?? Math.round(width * 0.8)
|
|
148
|
-
|
|
149
|
-
// Word wrapping via tspan elements.
|
|
150
|
-
// Width estimation: Latin chars ~0.55em, CJK chars ~1.0em, narrow chars ~0.35em.
|
|
151
|
-
const words = text.split(' ')
|
|
152
|
-
const lines: string[] = []
|
|
153
|
-
let currentLine = ''
|
|
154
|
-
|
|
155
|
-
const estimateWidth = (s: string): number => {
|
|
156
|
-
let w = 0
|
|
157
|
-
for (let i = 0; i < s.length; i++) {
|
|
158
|
-
const code = s.charCodeAt(i)
|
|
159
|
-
if (code >= 0x3000 && code <= 0x9FFF) {
|
|
160
|
-
// CJK characters — full width
|
|
161
|
-
w += fontSize * 1.0
|
|
162
|
-
} else if (code <= 0x7E && 'iljft!|:;.,\''.includes(s[i]!)) {
|
|
163
|
-
// Narrow Latin characters
|
|
164
|
-
w += fontSize * 0.35
|
|
165
|
-
} else {
|
|
166
|
-
// Regular Latin characters
|
|
167
|
-
w += fontSize * 0.55
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return w
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
for (const word of words) {
|
|
174
|
-
const testLine = currentLine ? `${currentLine} ${word}` : word
|
|
175
|
-
if (estimateWidth(testLine) > maxWidth && currentLine) {
|
|
176
|
-
lines.push(currentLine)
|
|
177
|
-
currentLine = word
|
|
178
|
-
} else {
|
|
179
|
-
currentLine = testLine
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (currentLine) lines.push(currentLine)
|
|
183
|
-
|
|
184
|
-
const tspans = lines
|
|
185
|
-
.map((line, i) => {
|
|
186
|
-
const dy = i === 0 ? '0' : `${fontSize * 1.2}`
|
|
187
|
-
return `<tspan x="${x}" dy="${dy}">${escapeXml(line)}</tspan>`
|
|
188
|
-
})
|
|
189
|
-
.join('')
|
|
190
|
-
|
|
191
|
-
return `<text x="${x}" y="${y}" font-size="${fontSize}" font-family="${escapeXml(fontFamily)}" font-weight="${fontWeight}" fill="${color}" text-anchor="${anchor}" dominant-baseline="middle">${tspans}</text>`
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${textElements.join('')}</svg>`
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Render an OG image from a template for a specific locale.
|
|
199
|
-
* @internal Exported for testing.
|
|
200
|
-
*/
|
|
201
|
-
export async function renderOgImage(
|
|
202
|
-
template: OgImageTemplate,
|
|
203
|
-
locale: string,
|
|
204
|
-
rootDir: string,
|
|
205
|
-
): Promise<Uint8Array | null> {
|
|
206
|
-
try {
|
|
207
|
-
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
208
|
-
const width = template.width ?? 1200
|
|
209
|
-
const height = template.height ?? 630
|
|
210
|
-
|
|
211
|
-
let pipeline: any
|
|
212
|
-
if (typeof template.background === 'string') {
|
|
213
|
-
const bgPath = join(rootDir, template.background)
|
|
214
|
-
pipeline = sharp(bgPath).resize(width, height, { fit: 'cover' })
|
|
215
|
-
} else {
|
|
216
|
-
pipeline = (sharp as any)({
|
|
217
|
-
create: {
|
|
218
|
-
width,
|
|
219
|
-
height,
|
|
220
|
-
channels: 4,
|
|
221
|
-
background: template.background.color,
|
|
222
|
-
},
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Overlay text layers if any
|
|
227
|
-
if (template.layers && template.layers.length > 0) {
|
|
228
|
-
const svgOverlay = buildTextOverlaySvg(template.layers, width, height, locale)
|
|
229
|
-
pipeline = pipeline.composite([{
|
|
230
|
-
input: Buffer.from(svgOverlay),
|
|
231
|
-
top: 0,
|
|
232
|
-
left: 0,
|
|
233
|
-
}])
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (template.format === 'jpeg') {
|
|
237
|
-
return await pipeline.jpeg({ quality: template.quality ?? 90 }).toBuffer()
|
|
238
|
-
}
|
|
239
|
-
return await pipeline.png().toBuffer()
|
|
240
|
-
} catch {
|
|
241
|
-
warnSharpMissing()
|
|
242
|
-
return null
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ─── Path utility ───────────────────────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Compute the OG image path for a template and locale.
|
|
250
|
-
*
|
|
251
|
-
* @example
|
|
252
|
-
* ```ts
|
|
253
|
-
* ogImagePath("default", "de") // → "/og/default-de.png"
|
|
254
|
-
* ogImagePath("default") // → "/og/default.png"
|
|
255
|
-
* ogImagePath("hero", "en", "images") // → "/images/hero-en.png"
|
|
256
|
-
* ```
|
|
257
|
-
*/
|
|
258
|
-
export function ogImagePath(
|
|
259
|
-
templateName: string,
|
|
260
|
-
locale?: string,
|
|
261
|
-
outDir = 'og',
|
|
262
|
-
format: 'png' | 'jpeg' = 'png',
|
|
263
|
-
): string {
|
|
264
|
-
const ext = format === 'jpeg' ? 'jpg' : 'png'
|
|
265
|
-
const suffix = locale ? `-${locale}` : ''
|
|
266
|
-
return `/${outDir}/${templateName}${suffix}.${ext}`
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ─── Vite plugin ────────────────────────────────────────────────────────────
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* OG image generation Vite plugin.
|
|
273
|
-
*
|
|
274
|
-
* Generates Open Graph images at build time. In dev, generates on-demand.
|
|
275
|
-
* Requires `sharp` as an optional dependency.
|
|
276
|
-
*
|
|
277
|
-
* @example
|
|
278
|
-
* ```ts
|
|
279
|
-
* // vite.config.ts
|
|
280
|
-
* import { ogImagePlugin } from "@pyreon/zero/og-image"
|
|
281
|
-
*
|
|
282
|
-
* export default {
|
|
283
|
-
* plugins: [
|
|
284
|
-
* ogImagePlugin({
|
|
285
|
-
* locales: ["en", "de"],
|
|
286
|
-
* templates: [{
|
|
287
|
-
* name: "default",
|
|
288
|
-
* background: { color: "#0066ff" },
|
|
289
|
-
* layers: [{ text: { en: "Hello", de: "Hallo" }, fontSize: 72 }],
|
|
290
|
-
* }],
|
|
291
|
-
* }),
|
|
292
|
-
* ],
|
|
293
|
-
* }
|
|
294
|
-
* ```
|
|
295
|
-
*/
|
|
296
|
-
export function ogImagePlugin(config: OgImagePluginConfig): Plugin {
|
|
297
|
-
const outDir = config.outDir ?? 'og'
|
|
298
|
-
let root = ''
|
|
299
|
-
let isBuild = false
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
name: 'pyreon-zero-og-image',
|
|
303
|
-
enforce: 'pre',
|
|
304
|
-
|
|
305
|
-
configResolved(resolvedConfig) {
|
|
306
|
-
root = resolvedConfig.root
|
|
307
|
-
isBuild = resolvedConfig.command === 'build'
|
|
308
|
-
},
|
|
309
|
-
|
|
310
|
-
// Dev: generate on-demand
|
|
311
|
-
configureServer(server) {
|
|
312
|
-
const devCache = new Map<string, Uint8Array>()
|
|
313
|
-
|
|
314
|
-
server.middlewares.use(async (req, res, next) => {
|
|
315
|
-
const url = req.url ?? ''
|
|
316
|
-
if (!url.startsWith(`/${outDir}/`)) return next()
|
|
317
|
-
|
|
318
|
-
// Parse: /og/default-en.png → template=default, locale=en
|
|
319
|
-
const fileName = url.slice(outDir.length + 2) // strip /{outDir}/
|
|
320
|
-
const match = fileName.match(/^(.+?)(?:-([a-z]{2,5}))?\.(png|jpe?g)$/)
|
|
321
|
-
if (!match) return next()
|
|
322
|
-
|
|
323
|
-
const [, templateName, locale, ext] = match
|
|
324
|
-
const template = config.templates.find((t) => t.name === templateName)
|
|
325
|
-
if (!template) return next()
|
|
326
|
-
|
|
327
|
-
const resolvedLocale = locale ?? config.locales?.[0] ?? 'en'
|
|
328
|
-
const cacheKey = `${templateName}:${resolvedLocale}`
|
|
329
|
-
|
|
330
|
-
let buffer = devCache.get(cacheKey)
|
|
331
|
-
if (!buffer) {
|
|
332
|
-
const result = await renderOgImage(template, resolvedLocale, root)
|
|
333
|
-
if (!result) return next()
|
|
334
|
-
buffer = result
|
|
335
|
-
devCache.set(cacheKey, result)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const contentType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png'
|
|
339
|
-
res.setHeader('Content-Type', contentType)
|
|
340
|
-
res.setHeader('Cache-Control', 'no-cache')
|
|
341
|
-
res.end(Buffer.from(buffer))
|
|
342
|
-
})
|
|
343
|
-
},
|
|
344
|
-
|
|
345
|
-
// Build: generate all variants
|
|
346
|
-
async generateBundle() {
|
|
347
|
-
if (!isBuild) return
|
|
348
|
-
|
|
349
|
-
for (const template of config.templates) {
|
|
350
|
-
const locales = config.locales ?? [undefined]
|
|
351
|
-
const format = template.format ?? 'png'
|
|
352
|
-
const ext = format === 'jpeg' ? 'jpg' : 'png'
|
|
353
|
-
|
|
354
|
-
for (const locale of locales) {
|
|
355
|
-
// Validate background exists if it's a file path
|
|
356
|
-
if (typeof template.background === 'string') {
|
|
357
|
-
const bgPath = join(root, template.background)
|
|
358
|
-
if (!existsSync(bgPath)) {
|
|
359
|
-
// oxlint-disable-next-line no-console
|
|
360
|
-
console.warn(`[Pyreon] Background not found: ${bgPath}`)
|
|
361
|
-
continue
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const buffer = await renderOgImage(template, locale ?? 'en', root)
|
|
366
|
-
if (!buffer) continue
|
|
367
|
-
|
|
368
|
-
const suffix = locale ? `-${locale}` : ''
|
|
369
|
-
this.emitFile({
|
|
370
|
-
type: 'asset',
|
|
371
|
-
fileName: `${outDir}/${template.name}${suffix}.${ext}`,
|
|
372
|
-
source: buffer,
|
|
373
|
-
})
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
}
|
|
378
|
-
}
|
package/src/rate-limit.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
2
|
-
|
|
3
|
-
// ─── Rate limiting middleware ───────────────────────────────────────────────
|
|
4
|
-
|
|
5
|
-
export interface RateLimitConfig {
|
|
6
|
-
/** Maximum requests per window. Default: `100` */
|
|
7
|
-
max?: number
|
|
8
|
-
/** Time window in seconds. Default: `60` */
|
|
9
|
-
window?: number
|
|
10
|
-
/** Function to extract the client identifier. Default: IP from headers. */
|
|
11
|
-
keyFn?: (ctx: MiddlewareContext) => string
|
|
12
|
-
/** Custom response when rate limited. */
|
|
13
|
-
onLimit?: (ctx: MiddlewareContext) => Response
|
|
14
|
-
/** URL patterns to rate limit (glob-style). Default: all paths. */
|
|
15
|
-
include?: string[]
|
|
16
|
-
/** URL patterns to exclude from rate limiting. */
|
|
17
|
-
exclude?: string[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface RateLimitEntry {
|
|
21
|
-
count: number
|
|
22
|
-
resetAt: number
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Rate limiting middleware — limits requests per client within a time window.
|
|
27
|
-
* Uses an in-memory store (suitable for single-instance deployments).
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
|
|
31
|
-
*
|
|
32
|
-
* // 100 requests per minute (default)
|
|
33
|
-
* rateLimitMiddleware()
|
|
34
|
-
*
|
|
35
|
-
* // Strict API rate limiting
|
|
36
|
-
* rateLimitMiddleware({
|
|
37
|
-
* max: 20,
|
|
38
|
-
* window: 60,
|
|
39
|
-
* include: ["/api/*"],
|
|
40
|
-
* })
|
|
41
|
-
*/
|
|
42
|
-
export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
43
|
-
const {
|
|
44
|
-
max = 100,
|
|
45
|
-
window: windowSec = 60,
|
|
46
|
-
keyFn = defaultKeyFn,
|
|
47
|
-
onLimit,
|
|
48
|
-
include,
|
|
49
|
-
exclude,
|
|
50
|
-
} = config
|
|
51
|
-
|
|
52
|
-
const windowMs = windowSec * 1000
|
|
53
|
-
const store = new Map<string, RateLimitEntry>()
|
|
54
|
-
const MAX_STORE_SIZE = 10000
|
|
55
|
-
let lastCleanup = Date.now()
|
|
56
|
-
|
|
57
|
-
// Inline cleanup — runs during request processing, no setInterval needed.
|
|
58
|
-
// Evicts expired entries when store exceeds half capacity or on window boundary.
|
|
59
|
-
function cleanupIfNeeded(now: number) {
|
|
60
|
-
if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return
|
|
61
|
-
lastCleanup = now
|
|
62
|
-
for (const [key, entry] of store) {
|
|
63
|
-
if (entry.resetAt <= now) store.delete(key)
|
|
64
|
-
}
|
|
65
|
-
// HARD cap. The expired-sweep above only removes entries whose
|
|
66
|
-
// window has elapsed. An attacker flooding unique keys WITHIN one
|
|
67
|
-
// window (spoofed `X-Forwarded-For` / proxy header — `defaultKeyFn`
|
|
68
|
-
// trusts request headers) produces only fresh entries, so the sweep
|
|
69
|
-
// frees nothing and `store.set` grows the Map without bound — an
|
|
70
|
-
// unauthenticated memory-exhaustion DoS. `MAX_STORE_SIZE` was a
|
|
71
|
-
// declared constant used ONLY as a sweep trigger, never enforced.
|
|
72
|
-
// Map preserves insertion order, so evicting from the front drops
|
|
73
|
-
// the oldest trackers first (acceptable: an evicted attacker key
|
|
74
|
-
// simply gets a fresh window — no bypass of legitimate limits since
|
|
75
|
-
// a real client re-inserts and is immediately re-tracked).
|
|
76
|
-
while (store.size > MAX_STORE_SIZE) {
|
|
77
|
-
const oldest = store.keys().next().value
|
|
78
|
-
if (oldest === undefined) break
|
|
79
|
-
store.delete(oldest)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return (ctx: MiddlewareContext) => {
|
|
84
|
-
// Check include/exclude patterns
|
|
85
|
-
if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return
|
|
86
|
-
if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return
|
|
87
|
-
|
|
88
|
-
const key = keyFn(ctx)
|
|
89
|
-
const now = Date.now()
|
|
90
|
-
|
|
91
|
-
cleanupIfNeeded(now)
|
|
92
|
-
|
|
93
|
-
let entry = store.get(key)
|
|
94
|
-
|
|
95
|
-
if (!entry || entry.resetAt <= now) {
|
|
96
|
-
entry = { count: 0, resetAt: now + windowMs }
|
|
97
|
-
store.set(key, entry)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
entry.count++
|
|
101
|
-
const remaining = Math.max(0, max - entry.count)
|
|
102
|
-
const resetSeconds = Math.ceil((entry.resetAt - now) / 1000)
|
|
103
|
-
|
|
104
|
-
// Set rate limit headers on all responses
|
|
105
|
-
ctx.headers.set('X-RateLimit-Limit', String(max))
|
|
106
|
-
ctx.headers.set('X-RateLimit-Remaining', String(remaining))
|
|
107
|
-
ctx.headers.set('X-RateLimit-Reset', String(resetSeconds))
|
|
108
|
-
|
|
109
|
-
if (entry.count > max) {
|
|
110
|
-
if (onLimit) return onLimit(ctx)
|
|
111
|
-
|
|
112
|
-
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
|
113
|
-
status: 429,
|
|
114
|
-
headers: {
|
|
115
|
-
'Content-Type': 'application/json',
|
|
116
|
-
'Retry-After': String(resetSeconds),
|
|
117
|
-
'X-RateLimit-Limit': String(max),
|
|
118
|
-
'X-RateLimit-Remaining': '0',
|
|
119
|
-
'X-RateLimit-Reset': String(resetSeconds),
|
|
120
|
-
},
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function defaultKeyFn(ctx: MiddlewareContext): string {
|
|
127
|
-
return (
|
|
128
|
-
ctx.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
129
|
-
ctx.req.headers.get('x-real-ip') ??
|
|
130
|
-
'unknown'
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Simple glob matching for path patterns. Supports trailing `*`. */
|
|
135
|
-
function matchSimpleGlob(pattern: string, path: string): boolean {
|
|
136
|
-
if (pattern.endsWith('/*')) {
|
|
137
|
-
return path.startsWith(pattern.slice(0, -1))
|
|
138
|
-
}
|
|
139
|
-
return pattern === path
|
|
140
|
-
}
|