ddys-api-cache 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/.env.example ADDED
@@ -0,0 +1,8 @@
1
+ DDYS_API_CACHE_API_BASE=https://ddys.io/api/v1
2
+ DDYS_API_CACHE_ALLOWED_ENDPOINTS=/latest,/hot,/calendar,/search,/detail
3
+ DDYS_API_CACHE_TTL_SECONDS=300
4
+ DDYS_API_CACHE_STALE_SECONDS=3600
5
+ DDYS_API_CACHE_TIMEOUT_MS=10000
6
+ DDYS_API_CACHE_ADAPTER=memory
7
+ DDYS_API_CACHE_WORKER_CACHE=true
8
+ DDYS_API_CACHE_WARMUP_ENDPOINTS=/latest,/hot,/calendar
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ddysiodev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,76 @@
1
+ # ddys-api-cache
2
+
3
+ Standalone caching, stale fallback, and edge proxy toolkit for DDYS API integrations. It adds TTL caching, stale fallback, endpoint allowlists, request timeouts, and diagnostic headers for `ddys-feed`, `ddys-sitemap`, `ddys-opml`, `ddys-search-index`, or third-party frontends.
4
+
5
+ ## Features
6
+
7
+ - Node API with `fetchCached()`, `warmCache()`, and `createMemoryCache()`
8
+ - Cloudflare Workers `/api/*` proxy with `x-ddys-cache`, `x-ddys-upstream-ms`, and ETag support
9
+ - Fresh hit, miss, stale fallback, and force refresh modes
10
+ - Memory adapter and Cache API adapter
11
+ - Endpoint allowlist, query key filtering, query limits, and GET/HEAD method restrictions
12
+ - CLI commands for `fetch`, `warm`, and `diag`
13
+ - GitHub Actions warmup example
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install ddys-api-cache
19
+ ```
20
+
21
+ ## CLI
22
+
23
+ ```bash
24
+ ddys-api-cache diag
25
+ ddys-api-cache fetch --path /latest --query limit=20
26
+ ddys-api-cache warm --endpoints /latest,/hot,/calendar
27
+ ```
28
+
29
+ ## Node API
30
+
31
+ ```js
32
+ import { createDdysApiCache } from 'ddys-api-cache';
33
+
34
+ const cache = createDdysApiCache({
35
+ ttlSeconds: 300,
36
+ staleSeconds: 3600
37
+ });
38
+
39
+ const result = await cache.fetch('/latest', { query: { limit: 20 } });
40
+ console.log(result.cacheStatus, await result.json());
41
+ ```
42
+
43
+ ## Cloudflare Workers
44
+
45
+ ```js
46
+ import { createWorkerHandler } from 'ddys-api-cache/worker';
47
+
48
+ export default {
49
+ fetch: createWorkerHandler({
50
+ cacheAdapter: 'cache-api',
51
+ ttlSeconds: 300,
52
+ staleSeconds: 3600
53
+ })
54
+ };
55
+ ```
56
+
57
+ Routes:
58
+
59
+ - `/api/latest?limit=20`
60
+ - `/api/hot`
61
+ - `/api/calendar`
62
+ - `/api/search?q=keyword`
63
+ - `/health`
64
+ - `/diag`
65
+
66
+ ## Environment
67
+
68
+ ```bash
69
+ DDYS_API_CACHE_API_BASE=https://ddys.io/api/v1
70
+ DDYS_API_CACHE_ALLOWED_ENDPOINTS=/latest,/hot,/calendar,/search,/detail
71
+ DDYS_API_CACHE_TTL_SECONDS=300
72
+ DDYS_API_CACHE_STALE_SECONDS=3600
73
+ DDYS_API_CACHE_TIMEOUT_MS=10000
74
+ DDYS_API_CACHE_ADAPTER=memory
75
+ DDYS_API_CACHE_WORKER_CACHE=true
76
+ ```
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # ddys-api-cache
2
+
3
+ 低端影视 API 的独立缓存、降级和边缘反代工具包。它可以给 DDYS API 请求增加 TTL 缓存、stale fallback、白名单 endpoint、超时控制和诊断 header,适合给 `ddys-feed`、`ddys-sitemap`、`ddys-opml`、`ddys-search-index` 或第三方前端做稳定的 API 入口。
4
+
5
+ ## 功能
6
+
7
+ - Node API:`fetchCached()`、`warmCache()`、`createMemoryCache()`
8
+ - Cloudflare Workers:`/api/*` 反代 DDYS API,支持 `x-ddys-cache`、`x-ddys-upstream-ms`、ETag
9
+ - 缓存策略:fresh hit、miss、stale fallback、force refresh
10
+ - 缓存适配:Memory adapter、Cache API adapter
11
+ - 安全控制:endpoint 白名单、query key 过滤、query 数量和长度限制、GET/HEAD 方法限制
12
+ - CLI:`fetch` 调试请求、`warm` 预热接口、`diag` 查看配置
13
+ - GitHub Actions 示例:定时预热热门接口
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install ddys-api-cache
19
+ ```
20
+
21
+ ## CLI
22
+
23
+ ```bash
24
+ ddys-api-cache diag
25
+ ddys-api-cache fetch --path /latest --query limit=20
26
+ ddys-api-cache warm --endpoints /latest,/hot,/calendar
27
+ ```
28
+
29
+ 常用参数:
30
+
31
+ - `--api-base`:DDYS API 根地址
32
+ - `--allowed-endpoints`:允许反代的 endpoint 列表
33
+ - `--ttl-seconds`:新鲜缓存时间
34
+ - `--stale-seconds`:上游失败时允许使用旧缓存的时间
35
+ - `--timeout-ms`:上游请求超时
36
+ - `--adapter`:`memory`、`cache-api` 或 `auto`
37
+
38
+ ## Node API
39
+
40
+ ```js
41
+ import { createDdysApiCache } from 'ddys-api-cache';
42
+
43
+ const cache = createDdysApiCache({
44
+ ttlSeconds: 300,
45
+ staleSeconds: 3600
46
+ });
47
+
48
+ const result = await cache.fetch('/latest', { query: { limit: 20 } });
49
+ console.log(result.cacheStatus, await result.json());
50
+ ```
51
+
52
+ ## Cloudflare Workers
53
+
54
+ ```js
55
+ import { createWorkerHandler } from 'ddys-api-cache/worker';
56
+
57
+ export default {
58
+ fetch: createWorkerHandler({
59
+ cacheAdapter: 'cache-api',
60
+ ttlSeconds: 300,
61
+ staleSeconds: 3600
62
+ })
63
+ };
64
+ ```
65
+
66
+ Worker 路由:
67
+
68
+ - `/api/latest?limit=20`
69
+ - `/api/hot`
70
+ - `/api/calendar`
71
+ - `/api/search?q=关键词`
72
+ - `/health`
73
+ - `/diag`
74
+
75
+ ## 环境变量
76
+
77
+ ```bash
78
+ DDYS_API_CACHE_API_BASE=https://ddys.io/api/v1
79
+ DDYS_API_CACHE_ALLOWED_ENDPOINTS=/latest,/hot,/calendar,/search,/detail
80
+ DDYS_API_CACHE_TTL_SECONDS=300
81
+ DDYS_API_CACHE_STALE_SECONDS=3600
82
+ DDYS_API_CACHE_TIMEOUT_MS=10000
83
+ DDYS_API_CACHE_ADAPTER=memory
84
+ DDYS_API_CACHE_WORKER_CACHE=true
85
+ ```
86
+
87
+ ## 诊断 Header
88
+
89
+ - `x-ddys-cache`:`HIT`、`MISS`、`STALE`、`BYPASS`
90
+ - `x-ddys-cache-age`:缓存年龄,单位秒
91
+ - `x-ddys-upstream-ms`:上游耗时
92
+ - `x-ddys-cache-key`:短缓存 key
93
+ - `x-ddys-api-cache-version`:包版本
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import {
4
+ createConfig,
5
+ createDdysApiCache,
6
+ describeConfig,
7
+ fetchCached,
8
+ warmCache
9
+ } from '../src/index.js';
10
+ import { VERSION } from '../src/config.js';
11
+ import { parseQueryString } from '../src/utils.js';
12
+
13
+ const args = parseArgs(process.argv.slice(2));
14
+ const command = args._[0] || 'diag';
15
+
16
+ try {
17
+ if (command === 'version' || args.version) {
18
+ console.log(VERSION);
19
+ } else if (command === 'diag') {
20
+ const config = createConfig(readCliConfig(args));
21
+ console.log(JSON.stringify({ ok: true, package: 'ddys-api-cache', config: describeConfig(config) }, null, 2));
22
+ } else if (command === 'fetch') {
23
+ const path = args.path || args.endpoint || args._[1] || '/latest';
24
+ const result = await fetchCached(path, {
25
+ ...readCliConfig(args),
26
+ query: parseQueryString(args.query || ''),
27
+ forceRefresh: Boolean(args.refresh)
28
+ });
29
+ const body = await readResultBody(result, args);
30
+ console.log(JSON.stringify({
31
+ ok: result.ok,
32
+ status: result.status,
33
+ cacheStatus: result.cacheStatus,
34
+ cacheKey: result.cacheKey,
35
+ upstreamUrl: result.upstreamUrl,
36
+ ageSeconds: result.ageSeconds,
37
+ headers: args.headers ? result.headers : undefined,
38
+ body
39
+ }, null, 2));
40
+ } else if (command === 'warm') {
41
+ const endpoints = args.endpoints || args.endpoint || args.path || '';
42
+ const result = await warmCache(endpoints, readCliConfig(args));
43
+ console.log(JSON.stringify(result, null, 2));
44
+ if (!result.ok) process.exitCode = 1;
45
+ } else {
46
+ throw new Error(`Unknown command: ${command}`);
47
+ }
48
+ } catch (error) {
49
+ console.error(JSON.stringify({ ok: false, message: error.message }, null, 2));
50
+ process.exit(1);
51
+ }
52
+
53
+ function readCliConfig(args) {
54
+ const out = {};
55
+ if (args['api-base'] || args.apiBase) out.apiBase = args['api-base'] || args.apiBase;
56
+ if (args['allowed-endpoints'] || args.allowedEndpoints) out.allowedEndpoints = args['allowed-endpoints'] || args.allowedEndpoints;
57
+ if (args['blocked-query-keys'] || args.blockedQueryKeys) out.blockedQueryKeys = args['blocked-query-keys'] || args.blockedQueryKeys;
58
+ if (args['ttl-seconds'] || args.ttlSeconds) out.ttlSeconds = args['ttl-seconds'] || args.ttlSeconds;
59
+ if (args['stale-seconds'] || args.staleSeconds) out.staleSeconds = args['stale-seconds'] || args.staleSeconds;
60
+ if (args['timeout-ms'] || args.timeoutMs) out.requestTimeoutMs = args['timeout-ms'] || args.timeoutMs;
61
+ if (args.adapter) out.cacheAdapter = args.adapter;
62
+ if (args.namespace) out.cacheNamespace = args.namespace;
63
+ if (args['max-memory-entries'] || args.maxMemoryEntries) out.maxMemoryEntries = args['max-memory-entries'] || args.maxMemoryEntries;
64
+ if (args.endpoints) out.warmupEndpoints = args.endpoints;
65
+ return out;
66
+ }
67
+
68
+ async function readResultBody(result, args) {
69
+ const text = await result.text();
70
+ if (args.raw) return text;
71
+ try {
72
+ return text ? JSON.parse(text) : null;
73
+ } catch {
74
+ return text;
75
+ }
76
+ }
77
+
78
+ function parseArgs(argv) {
79
+ const out = { _: [] };
80
+ for (let index = 0; index < argv.length; index += 1) {
81
+ const arg = argv[index];
82
+ if (!arg.startsWith('--')) {
83
+ out._.push(arg);
84
+ continue;
85
+ }
86
+ const key = arg.slice(2);
87
+ const next = argv[index + 1];
88
+ if (!next || next.startsWith('--')) {
89
+ out[key] = true;
90
+ continue;
91
+ }
92
+ out[key] = next;
93
+ index += 1;
94
+ }
95
+ return out;
96
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "ddys-api-cache-worker-example",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "wrangler dev",
7
+ "deploy": "wrangler deploy"
8
+ },
9
+ "dependencies": {
10
+ "ddys-api-cache": "^0.1.0"
11
+ },
12
+ "devDependencies": {
13
+ "wrangler": "^4.0.0"
14
+ }
15
+ }
@@ -0,0 +1,7 @@
1
+ import { createWorkerHandler } from 'ddys-api-cache/worker';
2
+
3
+ export default {
4
+ fetch: createWorkerHandler({
5
+ cacheAdapter: 'cache-api'
6
+ })
7
+ };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "ddys-api-cache",
3
+ "main": "src/index.js",
4
+ "compatibility_date": "2026-07-11",
5
+ "vars": {
6
+ "DDYS_API_CACHE_API_BASE": "https://ddys.io/api/v1",
7
+ "DDYS_API_CACHE_ALLOWED_ENDPOINTS": "/latest,/hot,/calendar,/search,/detail",
8
+ "DDYS_API_CACHE_TTL_SECONDS": "300",
9
+ "DDYS_API_CACHE_STALE_SECONDS": "3600",
10
+ "DDYS_API_CACHE_ADAPTER": "cache-api"
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ name: Warm DDYS API cache
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "*/15 * * * *"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ warm:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: 22
15
+ - run: npx ddys-api-cache warm --endpoints /latest,/hot,/calendar
16
+ env:
17
+ DDYS_API_CACHE_API_BASE: https://ddys.io/api/v1
18
+ DDYS_API_CACHE_TTL_SECONDS: "300"
19
+ DDYS_API_CACHE_STALE_SECONDS: "3600"
@@ -0,0 +1,15 @@
1
+ import { createDdysApiCache } from 'ddys-api-cache';
2
+
3
+ const cache = createDdysApiCache({
4
+ ttlSeconds: 300,
5
+ staleSeconds: 3600
6
+ });
7
+
8
+ const first = await cache.fetch('/latest', { query: { limit: 20 } });
9
+ const second = await cache.fetch('/latest', { query: { limit: 20 } });
10
+
11
+ console.log(JSON.stringify({
12
+ first: first.cacheStatus,
13
+ second: second.cacheStatus,
14
+ stats: cache.stats()
15
+ }, null, 2));
package/index.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ export interface DdysApiCacheConfig {
2
+ version: string;
3
+ apiBase: string;
4
+ userAgent: string;
5
+ allowedEndpoints: string[];
6
+ blockedQueryKeys: string[];
7
+ allowedMethods: string[];
8
+ warmupEndpoints: string[];
9
+ ttlSeconds: number;
10
+ staleSeconds: number;
11
+ requestTimeoutMs: number;
12
+ maxQueryParams: number;
13
+ maxQueryLength: number;
14
+ cacheAdapter: 'memory' | 'cache-api' | 'auto' | 'none';
15
+ cacheNamespace: string;
16
+ maxMemoryEntries: number;
17
+ workerCache: boolean;
18
+ }
19
+
20
+ export interface CacheEntry {
21
+ cacheKey: string;
22
+ upstreamUrl: string;
23
+ canonicalUrl: string;
24
+ status: number;
25
+ statusText: string;
26
+ headers: Record<string, string>;
27
+ body: string;
28
+ createdAt: number;
29
+ expiresAt: number;
30
+ staleUntil: number;
31
+ ttlSeconds: number;
32
+ staleSeconds: number;
33
+ etag: string;
34
+ }
35
+
36
+ export interface CacheAdapter {
37
+ name: string;
38
+ get(key: string): Promise<CacheEntry | null>;
39
+ set(key: string, entry: CacheEntry): Promise<void>;
40
+ delete?(key: string): Promise<void>;
41
+ clear?(): Promise<void>;
42
+ stats?(): Record<string, unknown>;
43
+ }
44
+
45
+ export interface CachedFetchResult {
46
+ ok: boolean;
47
+ status: number;
48
+ statusText: string;
49
+ cacheStatus: 'HIT' | 'MISS' | 'STALE' | 'BYPASS';
50
+ cacheKey: string;
51
+ upstreamUrl: string;
52
+ canonicalUrl: string;
53
+ ageSeconds: number;
54
+ stale: boolean;
55
+ durationMs: number;
56
+ method: string;
57
+ headers: Record<string, string>;
58
+ body: string;
59
+ error?: string;
60
+ text(): Promise<string>;
61
+ json(): Promise<unknown>;
62
+ }
63
+
64
+ export interface DdysApiCache {
65
+ config: DdysApiCacheConfig;
66
+ adapter: CacheAdapter;
67
+ fetch(pathname: string, options?: Record<string, unknown>): Promise<CachedFetchResult>;
68
+ warm(endpoints?: string[] | string, options?: Record<string, unknown>): Promise<WarmCacheResult>;
69
+ stats(): Record<string, unknown>;
70
+ describe(): Record<string, unknown>;
71
+ }
72
+
73
+ export interface WarmCacheResult {
74
+ ok: boolean;
75
+ total: number;
76
+ results: Array<{
77
+ ok: boolean;
78
+ path: string;
79
+ cacheStatus?: string;
80
+ status?: number;
81
+ cacheKey?: string;
82
+ upstreamUrl?: string;
83
+ message?: string;
84
+ }>;
85
+ }
86
+
87
+ export function createConfig(input?: Partial<DdysApiCacheConfig> | Record<string, unknown>, env?: Record<string, string | undefined>): DdysApiCacheConfig;
88
+ export function loadConfigFromEnv(env?: Record<string, string | undefined>): DdysApiCacheConfig;
89
+ export function describeConfig(config: DdysApiCacheConfig): Record<string, unknown>;
90
+ export function normalizeEndpointList(value: string[] | string): string[];
91
+ export function normalizeWarmupEndpoints(value: string[] | string): string[];
92
+ export function createMemoryCache(options?: { maxEntries?: number }): CacheAdapter;
93
+ export function createCacheApiAdapter(cacheApi: unknown, options?: { namespace?: string }): CacheAdapter;
94
+ export function createNullCache(): CacheAdapter;
95
+ export function createCacheAdapter(config: DdysApiCacheConfig, runtime?: Record<string, unknown>): CacheAdapter;
96
+ export function assertAllowedEndpoint(pathname: string, config: DdysApiCacheConfig): string;
97
+ export function assertAllowedMethod(method: string, config: DdysApiCacheConfig): string;
98
+ export function sanitizeQuery(query: string | URLSearchParams | Record<string, unknown>, config: DdysApiCacheConfig): URLSearchParams;
99
+ export function buildUpstreamRequest(pathname: string, query: string | URLSearchParams | Record<string, unknown>, config: DdysApiCacheConfig): { path: string; query: URLSearchParams; upstreamUrl: string; canonicalUrl: string };
100
+ export function createDdysApiCache(input?: Partial<DdysApiCacheConfig> | Record<string, unknown>, runtime?: Record<string, unknown>): DdysApiCache;
101
+ export function fetchCached(pathname: string, input?: Record<string, unknown>, runtime?: Record<string, unknown>): Promise<CachedFetchResult>;
102
+ export function warmCache(endpoints: string[] | string, input?: Record<string, unknown>, runtime?: Record<string, unknown>): Promise<WarmCacheResult>;
103
+ export function fetchUpstream(url: string, config: DdysApiCacheConfig, runtime?: { fetch?: typeof fetch; now?: () => number }): Promise<Record<string, unknown>>;
104
+ export function buildCacheKey(request: { canonicalUrl: string }, config: DdysApiCacheConfig): string;
105
+ export function buildResultHeaders(entry: CacheEntry, meta?: Record<string, unknown>): Record<string, string>;
106
+ export function createCacheEntry(input: Record<string, unknown>): CacheEntry;
107
+ export function createCachedResult(entry: CacheEntry, meta?: Record<string, unknown>): CachedFetchResult;
108
+ export function createWorkerHandler(input?: Record<string, unknown>): (request: Request, env?: Record<string, string | undefined>, ctx?: unknown) => Promise<Response>;
109
+ export function handleWorkerRequest(request: Request, runtime?: Record<string, unknown>): Promise<Response>;
110
+ export function routeForPath(pathname: string): 'health' | 'diag' | 'warm' | 'api' | 'not-found';
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "ddys-api-cache",
3
+ "version": "0.1.0",
4
+ "description": "Caching, stale fallback, and edge proxy toolkit for DDYS API integrations.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/ddysiodev/ddys-api-cache",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ddysiodev/ddys-api-cache.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ddysiodev/ddys-api-cache/issues"
14
+ },
15
+ "keywords": [
16
+ "ddys",
17
+ "api-cache",
18
+ "edge-cache",
19
+ "stale-while-revalidate",
20
+ "cloudflare-workers",
21
+ "github-actions",
22
+ "proxy",
23
+ "api"
24
+ ],
25
+ "bin": {
26
+ "ddys-api-cache": "./bin/ddys-api-cache.js"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./index.d.ts",
31
+ "import": "./src/index.js"
32
+ },
33
+ "./config": {
34
+ "types": "./index.d.ts",
35
+ "import": "./src/config.js"
36
+ },
37
+ "./cache": {
38
+ "types": "./index.d.ts",
39
+ "import": "./src/cache.js"
40
+ },
41
+ "./adapters": {
42
+ "types": "./index.d.ts",
43
+ "import": "./src/adapters.js"
44
+ },
45
+ "./worker": {
46
+ "types": "./index.d.ts",
47
+ "import": "./src/worker.js"
48
+ }
49
+ },
50
+ "main": "./src/index.js",
51
+ "types": "./index.d.ts",
52
+ "files": [
53
+ "bin",
54
+ "src",
55
+ "examples",
56
+ "index.d.ts",
57
+ "README.md",
58
+ "README.en.md",
59
+ "LICENSE",
60
+ ".env.example"
61
+ ],
62
+ "scripts": {
63
+ "check": "node tools/check.mjs",
64
+ "test": "node tools/check.mjs && node --test tests/*.test.mjs"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "engines": {
70
+ "node": ">=20.0.0"
71
+ }
72
+ }
@@ -0,0 +1,105 @@
1
+ import { hashString, nowMs } from './utils.js';
2
+
3
+ export function createMemoryCache(options = {}) {
4
+ const maxEntries = options.maxEntries || 500;
5
+ const entries = new Map();
6
+ const stats = {
7
+ gets: 0,
8
+ sets: 0,
9
+ deletes: 0,
10
+ clears: 0
11
+ };
12
+
13
+ return {
14
+ name: 'memory',
15
+ async get(key) {
16
+ stats.gets += 1;
17
+ const entry = entries.get(key);
18
+ if (!entry) return null;
19
+ entries.delete(key);
20
+ entries.set(key, entry);
21
+ return structuredCloneSafe(entry);
22
+ },
23
+ async set(key, entry) {
24
+ stats.sets += 1;
25
+ entries.set(key, structuredCloneSafe(entry));
26
+ while (entries.size > maxEntries) {
27
+ const oldest = entries.keys().next().value;
28
+ entries.delete(oldest);
29
+ }
30
+ },
31
+ async delete(key) {
32
+ stats.deletes += 1;
33
+ entries.delete(key);
34
+ },
35
+ async clear() {
36
+ stats.clears += 1;
37
+ entries.clear();
38
+ },
39
+ stats() {
40
+ return { ...stats, entries: entries.size, maxEntries };
41
+ }
42
+ };
43
+ }
44
+
45
+ export function createCacheApiAdapter(cacheApi, options = {}) {
46
+ const namespace = options.namespace || 'ddys-api-cache';
47
+ const cache = cacheApi?.default || cacheApi;
48
+ if (!cache?.match || !cache?.put) throw new Error('Cache API adapter requires a cache with match() and put().');
49
+ return {
50
+ name: 'cache-api',
51
+ async get(key) {
52
+ const response = await cache.match(cacheRequest(namespace, key));
53
+ if (!response) return null;
54
+ return response.json();
55
+ },
56
+ async set(key, entry) {
57
+ await cache.put(cacheRequest(namespace, key), new Response(JSON.stringify(entry), {
58
+ headers: {
59
+ 'content-type': 'application/json; charset=utf-8',
60
+ 'cache-control': `public, max-age=${Math.max(1, Math.ceil((entry.staleUntil - nowMs()) / 1000))}`
61
+ }
62
+ }));
63
+ },
64
+ async delete(key) {
65
+ if (cache.delete) await cache.delete(cacheRequest(namespace, key));
66
+ },
67
+ stats() {
68
+ return { adapter: 'cache-api', namespace };
69
+ }
70
+ };
71
+ }
72
+
73
+ export function createNullCache() {
74
+ return {
75
+ name: 'none',
76
+ async get() {
77
+ return null;
78
+ },
79
+ async set() {},
80
+ async delete() {},
81
+ async clear() {},
82
+ stats() {
83
+ return { adapter: 'none' };
84
+ }
85
+ };
86
+ }
87
+
88
+ export function createCacheAdapter(config, runtime = {}) {
89
+ if (runtime.adapter) return runtime.adapter;
90
+ if (config.cacheAdapter === 'none') return createNullCache();
91
+ if (config.cacheAdapter === 'cache-api') return createCacheApiAdapter(runtime.caches || globalThis.caches, { namespace: config.cacheNamespace });
92
+ if (config.cacheAdapter === 'auto' && (runtime.caches || globalThis.caches)) {
93
+ return createCacheApiAdapter(runtime.caches || globalThis.caches, { namespace: config.cacheNamespace });
94
+ }
95
+ return runtime.memoryCache || createMemoryCache({ maxEntries: config.maxMemoryEntries });
96
+ }
97
+
98
+ function cacheRequest(namespace, key) {
99
+ return new Request(`https://ddys-api-cache.local/${encodeURIComponent(namespace)}/${hashString(key, 32)}`);
100
+ }
101
+
102
+ function structuredCloneSafe(value) {
103
+ if (typeof structuredClone === 'function') return structuredClone(value);
104
+ return JSON.parse(JSON.stringify(value));
105
+ }
package/src/cache.js ADDED
@@ -0,0 +1,237 @@
1
+ import { createConfig, describeConfig } from './config.js';
2
+ import { createCacheAdapter, createMemoryCache } from './adapters.js';
3
+ import { assertAllowedMethod, buildUpstreamRequest } from './security.js';
4
+ import {
5
+ createEtag,
6
+ createTimeoutSignal,
7
+ hashString,
8
+ normalizeHeaderObject,
9
+ nowMs,
10
+ parseQueryString,
11
+ sanitizeResponseHeaders
12
+ } from './utils.js';
13
+
14
+ export function createDdysApiCache(input = {}, runtime = {}) {
15
+ const config = createConfig(input, input.env || runtime.env);
16
+ const memoryCache = runtime.memoryCache || createMemoryCache({ maxEntries: config.maxMemoryEntries });
17
+ const adapter = runtime.adapter || createCacheAdapter(config, { ...runtime, memoryCache });
18
+
19
+ return {
20
+ config,
21
+ adapter,
22
+ fetch: (pathname, options = {}) => fetchCached(pathname, { ...config, ...options, adapter }, { ...runtime, memoryCache }),
23
+ warm: (endpoints = config.warmupEndpoints, options = {}) => warmCache(endpoints, { ...config, ...options, adapter }, { ...runtime, memoryCache }),
24
+ stats: () => adapter.stats?.() || {},
25
+ describe: () => describeConfig(config)
26
+ };
27
+ }
28
+
29
+ export async function fetchCached(pathname, input = {}, runtime = {}) {
30
+ const startedAt = nowMs(runtime);
31
+ const config = createConfig(input, input.env || runtime.env);
32
+ const adapter = input.adapter || runtime.adapter || createCacheAdapter(config, runtime);
33
+ const method = assertAllowedMethod(input.method || runtime.method || 'GET', config);
34
+ const query = input.query || parseQueryString(String(pathname || '').split('?')[1] || '');
35
+ const request = buildUpstreamRequest(pathname, query, config);
36
+ const cacheKey = buildCacheKey(request, config);
37
+ const now = nowMs(runtime);
38
+ const forceRefresh = Boolean(input.forceRefresh || runtime.forceRefresh);
39
+ const cached = forceRefresh ? null : await adapter.get(cacheKey);
40
+
41
+ if (cached && cached.expiresAt > now) {
42
+ return createCachedResult(cached, {
43
+ cacheStatus: 'HIT',
44
+ cacheKey,
45
+ request,
46
+ now,
47
+ startedAt,
48
+ method
49
+ });
50
+ }
51
+
52
+ let stale = null;
53
+ if (cached && cached.staleUntil > now) stale = cached;
54
+
55
+ try {
56
+ const upstream = await fetchUpstream(request.upstreamUrl, config, runtime);
57
+ const entry = createCacheEntry({
58
+ cacheKey,
59
+ request,
60
+ upstream,
61
+ config,
62
+ now: nowMs(runtime)
63
+ });
64
+ if (upstream.ok) await adapter.set(cacheKey, entry);
65
+ return createCachedResult(entry, {
66
+ cacheStatus: forceRefresh ? 'BYPASS' : 'MISS',
67
+ cacheKey,
68
+ request,
69
+ now: nowMs(runtime),
70
+ startedAt,
71
+ method,
72
+ upstreamMs: upstream.durationMs
73
+ });
74
+ } catch (error) {
75
+ if (stale) {
76
+ return createCachedResult(stale, {
77
+ cacheStatus: 'STALE',
78
+ cacheKey,
79
+ request,
80
+ now,
81
+ startedAt,
82
+ method,
83
+ error
84
+ });
85
+ }
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ export async function warmCache(endpoints, input = {}, runtime = {}) {
91
+ const config = createConfig(input, input.env || runtime.env);
92
+ const adapter = input.adapter || runtime.adapter || createCacheAdapter(config, runtime);
93
+ const list = normalizeWarmupList(endpoints || config.warmupEndpoints);
94
+ const results = [];
95
+ for (const endpoint of list) {
96
+ try {
97
+ const result = await fetchCached(endpoint.path, {
98
+ ...config,
99
+ query: endpoint.query,
100
+ adapter,
101
+ forceRefresh: true
102
+ }, runtime);
103
+ results.push({
104
+ ok: true,
105
+ path: endpoint.path,
106
+ cacheStatus: result.cacheStatus,
107
+ status: result.status,
108
+ cacheKey: result.cacheKey,
109
+ upstreamUrl: result.upstreamUrl
110
+ });
111
+ } catch (error) {
112
+ results.push({
113
+ ok: false,
114
+ path: endpoint.path,
115
+ message: error.message
116
+ });
117
+ }
118
+ }
119
+ return {
120
+ ok: results.every((item) => item.ok),
121
+ total: results.length,
122
+ results
123
+ };
124
+ }
125
+
126
+ export async function fetchUpstream(url, config, runtime = {}) {
127
+ const fetchImpl = runtime.fetch || globalThis.fetch;
128
+ if (typeof fetchImpl !== 'function') throw new Error('A fetch implementation is required.');
129
+ const timeout = createTimeoutSignal(config.requestTimeoutMs);
130
+ const startedAt = nowMs(runtime);
131
+ let response;
132
+ try {
133
+ response = await fetchImpl(url, {
134
+ method: 'GET',
135
+ headers: {
136
+ accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
137
+ 'user-agent': config.userAgent
138
+ },
139
+ signal: timeout.signal
140
+ });
141
+ } catch (error) {
142
+ if (timeout.timedOut()) throw new Error(`DDYS upstream timed out after ${config.requestTimeoutMs}ms.`);
143
+ throw error;
144
+ } finally {
145
+ timeout.cancel();
146
+ }
147
+
148
+ const body = await response.text();
149
+ const headers = sanitizeResponseHeaders(response.headers);
150
+ return {
151
+ ok: response.ok,
152
+ status: response.status,
153
+ statusText: response.statusText,
154
+ headers,
155
+ body,
156
+ durationMs: Math.max(0, nowMs(runtime) - startedAt)
157
+ };
158
+ }
159
+
160
+ export function buildCacheKey(request, config) {
161
+ return `${config.cacheNamespace}:${hashString(`${request.canonicalUrl}`, 32)}`;
162
+ }
163
+
164
+ export function createCacheEntry({ cacheKey, request, upstream, config, now }) {
165
+ return {
166
+ cacheKey,
167
+ upstreamUrl: request.upstreamUrl,
168
+ canonicalUrl: request.canonicalUrl,
169
+ status: upstream.status,
170
+ statusText: upstream.statusText,
171
+ headers: upstream.headers,
172
+ body: upstream.body,
173
+ createdAt: now,
174
+ expiresAt: now + config.ttlSeconds * 1000,
175
+ staleUntil: now + (config.ttlSeconds + config.staleSeconds) * 1000,
176
+ ttlSeconds: config.ttlSeconds,
177
+ staleSeconds: config.staleSeconds,
178
+ etag: createEtag(upstream.body)
179
+ };
180
+ }
181
+
182
+ export function createCachedResult(entry, meta = {}) {
183
+ const ageSeconds = Math.max(0, Math.floor(((meta.now ?? Date.now()) - entry.createdAt) / 1000));
184
+ const headers = buildResultHeaders(entry, {
185
+ cacheStatus: meta.cacheStatus,
186
+ ageSeconds,
187
+ upstreamMs: meta.upstreamMs ?? 0,
188
+ cacheKey: meta.cacheKey,
189
+ error: meta.error
190
+ });
191
+ const result = {
192
+ ok: entry.status >= 200 && entry.status < 300,
193
+ status: entry.status,
194
+ statusText: entry.statusText,
195
+ cacheStatus: meta.cacheStatus,
196
+ cacheKey: meta.cacheKey || entry.cacheKey,
197
+ upstreamUrl: entry.upstreamUrl,
198
+ canonicalUrl: entry.canonicalUrl,
199
+ ageSeconds,
200
+ stale: meta.cacheStatus === 'STALE',
201
+ durationMs: Math.max(0, (meta.now ?? Date.now()) - (meta.startedAt ?? Date.now())),
202
+ method: meta.method || 'GET',
203
+ headers,
204
+ body: entry.body,
205
+ error: meta.error ? meta.error.message : undefined,
206
+ async text() {
207
+ return entry.body;
208
+ },
209
+ async json() {
210
+ return entry.body ? JSON.parse(entry.body) : null;
211
+ }
212
+ };
213
+ return result;
214
+ }
215
+
216
+ export function buildResultHeaders(entry, meta = {}) {
217
+ return {
218
+ ...normalizeHeaderObject(entry.headers),
219
+ 'cache-control': `public, max-age=${entry.ttlSeconds}, stale-while-revalidate=${entry.staleSeconds}`,
220
+ etag: entry.etag,
221
+ 'x-ddys-cache': meta.cacheStatus || 'MISS',
222
+ 'x-ddys-cache-age': String(meta.ageSeconds || 0),
223
+ 'x-ddys-cache-key': hashString(meta.cacheKey || entry.cacheKey, 12),
224
+ 'x-ddys-upstream-ms': String(meta.upstreamMs || 0),
225
+ 'x-ddys-api-cache-version': '0.1.0',
226
+ ...(meta.error ? { 'x-ddys-cache-error': String(meta.error.message || meta.error).slice(0, 160) } : {})
227
+ };
228
+ }
229
+
230
+ function normalizeWarmupList(value) {
231
+ const list = Array.isArray(value) ? value : String(value || '').split(/[,\n;]/).map((item) => item.trim()).filter(Boolean);
232
+ return list.map((item) => {
233
+ if (typeof item === 'object') return { path: item.path || item.endpoint || '/', query: item.query || {} };
234
+ const [path, query = ''] = String(item).split('?');
235
+ return { path, query: parseQueryString(query) };
236
+ });
237
+ }
package/src/config.js ADDED
@@ -0,0 +1,129 @@
1
+ import {
2
+ normalizeApiPath,
3
+ normalizeBaseUrl,
4
+ parseBoolean,
5
+ parseNonNegativeInteger,
6
+ parsePositiveInteger,
7
+ splitList
8
+ } from './utils.js';
9
+
10
+ export const VERSION = '0.1.0';
11
+
12
+ export const DEFAULT_CONFIG = {
13
+ version: VERSION,
14
+ apiBase: 'https://ddys.io/api/v1',
15
+ userAgent: `ddys-api-cache/${VERSION}`,
16
+ allowedEndpoints: ['/latest', '/hot', '/calendar', '/search', '/detail'],
17
+ blockedQueryKeys: ['token', 'access_token', 'api_key', 'apikey', 'key', 'secret', 'password'],
18
+ allowedMethods: ['GET', 'HEAD'],
19
+ warmupEndpoints: ['/latest', '/hot', '/calendar'],
20
+ ttlSeconds: 300,
21
+ staleSeconds: 3600,
22
+ requestTimeoutMs: 10000,
23
+ maxQueryParams: 24,
24
+ maxQueryLength: 2048,
25
+ cacheAdapter: 'memory',
26
+ cacheNamespace: 'ddys-api-cache',
27
+ maxMemoryEntries: 500,
28
+ workerCache: true
29
+ };
30
+
31
+ export function loadConfigFromEnv(env = getProcessEnv()) {
32
+ return createConfig({}, env);
33
+ }
34
+
35
+ export function createConfig(input = {}, env = input.env || getProcessEnv()) {
36
+ const envConfig = readEnvConfig(env || {});
37
+ const merged = {
38
+ ...DEFAULT_CONFIG,
39
+ ...envConfig,
40
+ ...input
41
+ };
42
+
43
+ merged.apiBase = normalizeBaseUrl(merged.apiBase, 'apiBase');
44
+ merged.allowedEndpoints = normalizeEndpointList(merged.allowedEndpoints);
45
+ merged.blockedQueryKeys = normalizeNameList(merged.blockedQueryKeys).map((item) => item.toLowerCase());
46
+ merged.allowedMethods = normalizeNameList(merged.allowedMethods).map((item) => item.toUpperCase());
47
+ merged.warmupEndpoints = normalizeWarmupEndpoints(merged.warmupEndpoints);
48
+ merged.ttlSeconds = parsePositiveInteger(merged.ttlSeconds, DEFAULT_CONFIG.ttlSeconds, 'ttlSeconds');
49
+ merged.staleSeconds = parseNonNegativeInteger(merged.staleSeconds, DEFAULT_CONFIG.staleSeconds, 'staleSeconds');
50
+ merged.requestTimeoutMs = parsePositiveInteger(merged.requestTimeoutMs, DEFAULT_CONFIG.requestTimeoutMs, 'requestTimeoutMs');
51
+ merged.maxQueryParams = parsePositiveInteger(merged.maxQueryParams, DEFAULT_CONFIG.maxQueryParams, 'maxQueryParams');
52
+ merged.maxQueryLength = parsePositiveInteger(merged.maxQueryLength, DEFAULT_CONFIG.maxQueryLength, 'maxQueryLength');
53
+ merged.maxMemoryEntries = parsePositiveInteger(merged.maxMemoryEntries, DEFAULT_CONFIG.maxMemoryEntries, 'maxMemoryEntries');
54
+ merged.cacheAdapter = normalizeCacheAdapter(merged.cacheAdapter);
55
+ merged.cacheNamespace = String(merged.cacheNamespace || DEFAULT_CONFIG.cacheNamespace).trim();
56
+ merged.workerCache = parseBoolean(merged.workerCache, DEFAULT_CONFIG.workerCache);
57
+ return merged;
58
+ }
59
+
60
+ export function readEnvConfig(env = {}) {
61
+ const out = {};
62
+ if (env.DDYS_API_CACHE_API_BASE) out.apiBase = env.DDYS_API_CACHE_API_BASE;
63
+ if (env.DDYS_API_CACHE_USER_AGENT) out.userAgent = env.DDYS_API_CACHE_USER_AGENT;
64
+ if (env.DDYS_API_CACHE_ALLOWED_ENDPOINTS) out.allowedEndpoints = env.DDYS_API_CACHE_ALLOWED_ENDPOINTS;
65
+ if (env.DDYS_API_CACHE_BLOCKED_QUERY_KEYS) out.blockedQueryKeys = env.DDYS_API_CACHE_BLOCKED_QUERY_KEYS;
66
+ if (env.DDYS_API_CACHE_ALLOWED_METHODS) out.allowedMethods = env.DDYS_API_CACHE_ALLOWED_METHODS;
67
+ if (env.DDYS_API_CACHE_WARMUP_ENDPOINTS) out.warmupEndpoints = env.DDYS_API_CACHE_WARMUP_ENDPOINTS;
68
+ if (env.DDYS_API_CACHE_TTL_SECONDS) out.ttlSeconds = env.DDYS_API_CACHE_TTL_SECONDS;
69
+ if (env.DDYS_API_CACHE_STALE_SECONDS) out.staleSeconds = env.DDYS_API_CACHE_STALE_SECONDS;
70
+ if (env.DDYS_API_CACHE_TIMEOUT_MS) out.requestTimeoutMs = env.DDYS_API_CACHE_TIMEOUT_MS;
71
+ if (env.DDYS_API_CACHE_MAX_QUERY_PARAMS) out.maxQueryParams = env.DDYS_API_CACHE_MAX_QUERY_PARAMS;
72
+ if (env.DDYS_API_CACHE_MAX_QUERY_LENGTH) out.maxQueryLength = env.DDYS_API_CACHE_MAX_QUERY_LENGTH;
73
+ if (env.DDYS_API_CACHE_ADAPTER) out.cacheAdapter = env.DDYS_API_CACHE_ADAPTER;
74
+ if (env.DDYS_API_CACHE_NAMESPACE) out.cacheNamespace = env.DDYS_API_CACHE_NAMESPACE;
75
+ if (env.DDYS_API_CACHE_MAX_MEMORY_ENTRIES) out.maxMemoryEntries = env.DDYS_API_CACHE_MAX_MEMORY_ENTRIES;
76
+ if (env.DDYS_API_CACHE_WORKER_CACHE) out.workerCache = env.DDYS_API_CACHE_WORKER_CACHE;
77
+ return out;
78
+ }
79
+
80
+ export function describeConfig(config) {
81
+ return {
82
+ version: config.version,
83
+ apiBase: config.apiBase,
84
+ allowedEndpoints: config.allowedEndpoints,
85
+ blockedQueryKeys: config.blockedQueryKeys,
86
+ allowedMethods: config.allowedMethods,
87
+ warmupEndpoints: config.warmupEndpoints,
88
+ ttlSeconds: config.ttlSeconds,
89
+ staleSeconds: config.staleSeconds,
90
+ requestTimeoutMs: config.requestTimeoutMs,
91
+ maxQueryParams: config.maxQueryParams,
92
+ maxQueryLength: config.maxQueryLength,
93
+ cacheAdapter: config.cacheAdapter,
94
+ cacheNamespace: config.cacheNamespace,
95
+ maxMemoryEntries: config.maxMemoryEntries,
96
+ workerCache: config.workerCache
97
+ };
98
+ }
99
+
100
+ export function normalizeEndpointList(value) {
101
+ const endpoints = (Array.isArray(value) ? value : splitList(value))
102
+ .map((item) => normalizeApiPath(item))
103
+ .filter((item) => item !== '/');
104
+ return endpoints.length ? [...new Set(endpoints)] : DEFAULT_CONFIG.allowedEndpoints;
105
+ }
106
+
107
+ export function normalizeWarmupEndpoints(value) {
108
+ const endpoints = (Array.isArray(value) ? value : splitList(value))
109
+ .map((item) => String(item || '').trim())
110
+ .filter(Boolean);
111
+ return endpoints.length ? [...new Set(endpoints)] : [];
112
+ }
113
+
114
+ function normalizeNameList(value) {
115
+ const names = (Array.isArray(value) ? value : splitList(value))
116
+ .map((item) => String(item || '').trim())
117
+ .filter(Boolean);
118
+ return names.length ? [...new Set(names)] : [];
119
+ }
120
+
121
+ function normalizeCacheAdapter(value) {
122
+ const adapter = String(value || 'memory').trim().toLowerCase();
123
+ if (!['memory', 'cache-api', 'auto', 'none'].includes(adapter)) throw new Error(`Unsupported cache adapter: ${value}`);
124
+ return adapter;
125
+ }
126
+
127
+ function getProcessEnv() {
128
+ return globalThis.process?.env || {};
129
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ export { createConfig, describeConfig, loadConfigFromEnv, normalizeEndpointList, normalizeWarmupEndpoints } from './config.js';
2
+ export { createMemoryCache, createCacheApiAdapter, createNullCache, createCacheAdapter } from './adapters.js';
3
+ export { assertAllowedEndpoint, assertAllowedMethod, buildUpstreamRequest, sanitizeQuery } from './security.js';
4
+ export {
5
+ buildCacheKey,
6
+ buildResultHeaders,
7
+ createCacheEntry,
8
+ createCachedResult,
9
+ createDdysApiCache,
10
+ fetchCached,
11
+ fetchUpstream,
12
+ warmCache
13
+ } from './cache.js';
14
+ export { createWorkerHandler, handleWorkerRequest, routeForPath } from './worker.js';
@@ -0,0 +1,45 @@
1
+ import { joinUrl, normalizeApiPath, queryToSearchParams, stableSearch } from './utils.js';
2
+
3
+ export function assertAllowedMethod(method, config) {
4
+ const normalized = String(method || 'GET').toUpperCase();
5
+ if (!config.allowedMethods.includes(normalized)) throw new Error(`HTTP method is not allowed: ${normalized}`);
6
+ return normalized;
7
+ }
8
+
9
+ export function assertAllowedEndpoint(pathname, config) {
10
+ const path = normalizeApiPath(pathname);
11
+ const allowed = config.allowedEndpoints.some((endpoint) => path === endpoint || path.startsWith(`${endpoint}/`));
12
+ if (!allowed) throw new Error(`DDYS API endpoint is not allowed: ${path}`);
13
+ return path;
14
+ }
15
+
16
+ export function sanitizeQuery(query, config) {
17
+ const input = queryToSearchParams(query);
18
+ const out = new URLSearchParams();
19
+ let count = 0;
20
+ for (const [key, value] of input.entries()) {
21
+ const normalizedKey = String(key || '').trim();
22
+ if (!normalizedKey) continue;
23
+ if (config.blockedQueryKeys.includes(normalizedKey.toLowerCase())) continue;
24
+ count += 1;
25
+ if (count > config.maxQueryParams) throw new Error(`Too many query parameters. Max: ${config.maxQueryParams}`);
26
+ out.append(normalizedKey, String(value));
27
+ }
28
+ const serialized = stableSearch(out);
29
+ if (serialized.length > config.maxQueryLength) throw new Error(`Query string is too long. Max: ${config.maxQueryLength}`);
30
+ return out;
31
+ }
32
+
33
+ export function buildUpstreamRequest(pathname, query, config) {
34
+ const path = assertAllowedEndpoint(pathname, config);
35
+ const params = sanitizeQuery(query, config);
36
+ const url = new URL(joinUrl(config.apiBase, path));
37
+ const serialized = stableSearch(params);
38
+ for (const [key, value] of new URLSearchParams(serialized).entries()) url.searchParams.append(key, value);
39
+ return {
40
+ path,
41
+ query: params,
42
+ upstreamUrl: url.toString(),
43
+ canonicalUrl: `${path}${serialized ? `?${serialized}` : ''}`
44
+ };
45
+ }
package/src/utils.js ADDED
@@ -0,0 +1,150 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function normalizeBaseUrl(value, name = 'url') {
4
+ const raw = String(value || '').trim();
5
+ if (!raw) throw new Error(`${name} is required.`);
6
+ let url;
7
+ try {
8
+ url = new URL(raw);
9
+ } catch {
10
+ throw new Error(`${name} must be a valid URL.`);
11
+ }
12
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${name} must use http or https.`);
13
+ return url.toString().replace(/\/+$/, '');
14
+ }
15
+
16
+ export function joinUrl(base, pathname = '') {
17
+ const left = String(base || '').replace(/\/+$/, '');
18
+ const right = String(pathname || '').replace(/^\/+/, '');
19
+ return right ? `${left}/${right}` : left;
20
+ }
21
+
22
+ export function splitList(value) {
23
+ if (Array.isArray(value)) return value.flatMap((item) => splitList(item));
24
+ return String(value || '')
25
+ .split(/[,\n;]/)
26
+ .map((item) => item.trim())
27
+ .filter(Boolean);
28
+ }
29
+
30
+ export function parseBoolean(value, fallback = false) {
31
+ if (value === undefined || value === null || value === '') return fallback;
32
+ if (typeof value === 'boolean') return value;
33
+ const normalized = String(value).trim().toLowerCase();
34
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
35
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
36
+ return fallback;
37
+ }
38
+
39
+ export function parsePositiveInteger(value, fallback, name = 'value') {
40
+ const number = Number(value);
41
+ if (Number.isInteger(number) && number > 0) return number;
42
+ if (fallback !== undefined) return fallback;
43
+ throw new Error(`${name} must be a positive integer.`);
44
+ }
45
+
46
+ export function parseNonNegativeInteger(value, fallback, name = 'value') {
47
+ const number = Number(value);
48
+ if (Number.isInteger(number) && number >= 0) return number;
49
+ if (fallback !== undefined) return fallback;
50
+ throw new Error(`${name} must be a non-negative integer.`);
51
+ }
52
+
53
+ export function hashString(value, length = 16) {
54
+ return createHash('sha1').update(String(value || '')).digest('hex').slice(0, length);
55
+ }
56
+
57
+ export function createEtag(value) {
58
+ return `"${hashString(value, 24)}"`;
59
+ }
60
+
61
+ export function normalizeApiPath(pathname) {
62
+ const raw = String(pathname || '/').trim();
63
+ if (/^https?:\/\//i.test(raw)) throw new Error('API path must be relative, not absolute.');
64
+ const withoutQuery = raw.split('?')[0];
65
+ const path = `/${withoutQuery.replace(/^\/+/, '')}`.replace(/\/{2,}/g, '/');
66
+ return path === '/' ? '/' : path.replace(/\/+$/, '');
67
+ }
68
+
69
+ export function normalizeHeaderObject(headers = {}) {
70
+ const out = {};
71
+ if (headers?.forEach) {
72
+ headers.forEach((value, key) => {
73
+ out[String(key).toLowerCase()] = String(value);
74
+ });
75
+ return out;
76
+ }
77
+ for (const [key, value] of Object.entries(headers || {})) {
78
+ if (value === undefined || value === null) continue;
79
+ out[String(key).toLowerCase()] = String(value);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export function sanitizeResponseHeaders(headers = {}) {
85
+ const input = normalizeHeaderObject(headers);
86
+ const blocked = new Set(['set-cookie', 'set-cookie2', 'connection', 'transfer-encoding', 'content-length']);
87
+ const out = {};
88
+ for (const [key, value] of Object.entries(input)) {
89
+ if (blocked.has(key)) continue;
90
+ out[key] = value;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export function createTimeoutSignal(ms) {
96
+ const controller = new AbortController();
97
+ let didTimeout = false;
98
+ const timer = setTimeout(() => {
99
+ didTimeout = true;
100
+ controller.abort();
101
+ }, ms);
102
+ return {
103
+ signal: controller.signal,
104
+ timedOut: () => didTimeout,
105
+ cancel: () => clearTimeout(timer)
106
+ };
107
+ }
108
+
109
+ export function parseQueryString(value) {
110
+ const out = {};
111
+ const params = new URLSearchParams(String(value || '').replace(/^\?/, ''));
112
+ for (const [key, item] of params.entries()) {
113
+ if (key in out) {
114
+ if (!Array.isArray(out[key])) out[key] = [out[key]];
115
+ out[key].push(item);
116
+ } else {
117
+ out[key] = item;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function queryToSearchParams(query = {}) {
124
+ if (query instanceof URLSearchParams) return new URLSearchParams(query);
125
+ if (typeof query === 'string') return new URLSearchParams(query.replace(/^\?/, ''));
126
+ const params = new URLSearchParams();
127
+ for (const [key, value] of Object.entries(query || {})) {
128
+ if (value === undefined || value === null || value === '') continue;
129
+ if (Array.isArray(value)) {
130
+ for (const item of value) params.append(key, String(item));
131
+ continue;
132
+ }
133
+ params.set(key, String(value));
134
+ }
135
+ return params;
136
+ }
137
+
138
+ export function stableSearch(params) {
139
+ const entries = [...params.entries()].sort(([aKey, aValue], [bKey, bValue]) => {
140
+ const keyCompare = aKey.localeCompare(bKey);
141
+ return keyCompare || aValue.localeCompare(bValue);
142
+ });
143
+ const out = new URLSearchParams();
144
+ for (const [key, value] of entries) out.append(key, value);
145
+ return out.toString();
146
+ }
147
+
148
+ export function nowMs(runtime = {}) {
149
+ return Number(runtime.now?.() ?? Date.now());
150
+ }
package/src/worker.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createConfig, describeConfig } from './config.js';
2
+ import { createMemoryCache } from './adapters.js';
3
+ import { fetchCached, warmCache } from './cache.js';
4
+ import { normalizeApiPath, parseQueryString } from './utils.js';
5
+
6
+ export function createWorkerHandler(input = {}) {
7
+ const memoryCache = createMemoryCache({ maxEntries: input.maxMemoryEntries || 500 });
8
+ return async function fetch(request, env = {}, ctx = {}) {
9
+ return handleWorkerRequest(request, {
10
+ ...input,
11
+ env,
12
+ ctx,
13
+ memoryCache,
14
+ caches: globalThis.caches
15
+ });
16
+ };
17
+ }
18
+
19
+ export async function handleWorkerRequest(request, runtime = {}) {
20
+ const url = new URL(request.url);
21
+ const route = routeForPath(url.pathname);
22
+ const config = createConfig(runtime, runtime.env || {});
23
+
24
+ if (route === 'health') {
25
+ return jsonResponse({ ok: true, service: 'ddys-api-cache', version: config.version }, 200, { 'cache-control': 'no-store' });
26
+ }
27
+
28
+ if (route === 'diag') {
29
+ return jsonResponse({ ok: true, config: describeConfig(config) }, 200, { 'cache-control': 'no-store' });
30
+ }
31
+
32
+ if (route === 'warm') {
33
+ const warmed = await warmCache(config.warmupEndpoints, config, runtime);
34
+ return jsonResponse(warmed, warmed.ok ? 200 : 207, { 'cache-control': 'no-store' });
35
+ }
36
+
37
+ if (route !== 'api') {
38
+ return jsonResponse({ ok: false, message: 'Not found' }, 404, { 'cache-control': 'no-store' });
39
+ }
40
+
41
+ const apiPath = normalizeApiPath(url.pathname.replace(/^\/api(?=\/|$)/, '') || '/');
42
+ const result = await fetchCached(apiPath, {
43
+ ...config,
44
+ method: request.method,
45
+ query: parseQueryString(url.search),
46
+ adapter: runtime.adapter
47
+ }, runtime);
48
+
49
+ if (request.headers.get('if-none-match') && request.headers.get('if-none-match') === result.headers.etag) {
50
+ return new Response(null, { status: 304, headers: result.headers });
51
+ }
52
+
53
+ return new Response(request.method === 'HEAD' ? null : result.body, {
54
+ status: result.status,
55
+ statusText: result.statusText,
56
+ headers: result.headers
57
+ });
58
+ }
59
+
60
+ export function routeForPath(pathname) {
61
+ const path = String(pathname || '/').replace(/\/+$/, '') || '/';
62
+ if (path === '/health') return 'health';
63
+ if (path === '/diag') return 'diag';
64
+ if (path === '/warm') return 'warm';
65
+ if (path === '/api' || path.startsWith('/api/')) return 'api';
66
+ return 'not-found';
67
+ }
68
+
69
+ function jsonResponse(body, status = 200, headers = {}) {
70
+ return new Response(`${JSON.stringify(body, null, 2)}\n`, {
71
+ status,
72
+ headers: {
73
+ 'content-type': 'application/json; charset=utf-8',
74
+ ...headers
75
+ }
76
+ });
77
+ }