payment-kit 1.27.2 → 1.28.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/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +10 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +32 -16
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +16 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +14 -2
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +1 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/models/customer.ts +23 -1
- package/api/src/store/models/payment-method.ts +4 -0
- package/api/src/store/models/price.ts +23 -14
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/run-build.js +390 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +585 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1553 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +69 -0
- package/cloudflare/wrangler.staging.json +66 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +26 -22
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/safe-did-address.tsx +75 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/customers/customers/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
// Payment Kit — Cloudflare Workers entry point
|
|
2
|
+
// Uses Hono for routing + imports original Express routes via shim adapter
|
|
3
|
+
|
|
4
|
+
// Register ethers.js CF Workers fetch adapter BEFORE any ethers imports.
|
|
5
|
+
// ethers v6 uses node:http by default, which is not available in CF Workers.
|
|
6
|
+
// This replaces the HTTP transport with native CF Workers fetch.
|
|
7
|
+
import { FetchRequest } from 'ethers';
|
|
8
|
+
|
|
9
|
+
import { Client as PgClient } from 'pg';
|
|
10
|
+
import postgres from 'postgres';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { cors } from 'hono/cors';
|
|
13
|
+
import { setDB } from './shims/sequelize-d1/model';
|
|
14
|
+
import { initialize } from '../api/src/store/models';
|
|
15
|
+
import { Sequelize } from './shims/sequelize-d1/sequelize-class';
|
|
16
|
+
|
|
17
|
+
// Import the original Express routes (esbuild aliases handle all deps)
|
|
18
|
+
import expressRoutes from '../api/src/routes/index';
|
|
19
|
+
import type { RouteEntry } from './shims/express-compat/index';
|
|
20
|
+
|
|
21
|
+
// Import cron instance for scheduled handler
|
|
22
|
+
import { cronInstance } from './shims/cron';
|
|
23
|
+
|
|
24
|
+
// Import queue utilities
|
|
25
|
+
import { setWaitUntil, setCFQueue, flushPendingJobs, runAllScheduledJobs, getHandler } from './shims/queue';
|
|
26
|
+
|
|
27
|
+
// Import crons init to register all cron jobs
|
|
28
|
+
import crons from '../api/src/crons/index';
|
|
29
|
+
|
|
30
|
+
// Import queue modules that are NOT transitively imported by routes/crons
|
|
31
|
+
// so their handlers register in the CF queue shim and events listeners attach.
|
|
32
|
+
import '../api/src/queues/refund';
|
|
33
|
+
import '../api/src/queues/checkout-session';
|
|
34
|
+
import '../api/src/queues/discount-status';
|
|
35
|
+
import '../api/src/queues/exchange-rate-health';
|
|
36
|
+
|
|
37
|
+
// Import security shim — initFromAuthService fetches EK from AUTH_SERVICE
|
|
38
|
+
import { initFromAuthService } from './shims/blocklet-sdk/security';
|
|
39
|
+
|
|
40
|
+
// D1 query timing — per-request accumulator for Server-Timing header
|
|
41
|
+
import { resetD1Timing, getD1Timing } from './shims/sequelize-d1/timing';
|
|
42
|
+
|
|
43
|
+
// D1 auto-retry wrapper — handles transient CF D1 errors (e.g. "Network connection lost")
|
|
44
|
+
import { withD1Retry } from './shims/sequelize-d1/retry';
|
|
45
|
+
|
|
46
|
+
// DID Connect: login routes proxied to blocklet-service, business actions (pay/subscribe) handled locally
|
|
47
|
+
import { attachDIDConnectRoutes } from './did-connect-auth';
|
|
48
|
+
|
|
49
|
+
FetchRequest.registerGetUrl(async (req: FetchRequest) => {
|
|
50
|
+
const resp = await fetch(req.url, {
|
|
51
|
+
method: req.method || 'GET',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: req.hasBody() ? req.body : undefined,
|
|
54
|
+
});
|
|
55
|
+
const body = new Uint8Array(await resp.arrayBuffer());
|
|
56
|
+
const headers: Record<string, string> = {};
|
|
57
|
+
resp.headers.forEach((v: string, k: string) => {
|
|
58
|
+
headers[k] = v;
|
|
59
|
+
});
|
|
60
|
+
return { statusCode: resp.status, statusMessage: resp.statusText, headers, body };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Caller identity resolved via AUTH_SERVICE RPC (blocklet-service)
|
|
64
|
+
interface CallerIdentityDTO {
|
|
65
|
+
did: string;
|
|
66
|
+
pk: string;
|
|
67
|
+
displayName: string;
|
|
68
|
+
avatar: string;
|
|
69
|
+
role: 'owner' | 'admin' | 'member' | 'guest';
|
|
70
|
+
authMethod: 'passkey' | 'did-connect' | 'access-key' | 'oauth' | 'email';
|
|
71
|
+
accessKeyId?: string;
|
|
72
|
+
approved: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Env {
|
|
76
|
+
DB: D1Database;
|
|
77
|
+
DID_CONNECT_KV: KVNamespace;
|
|
78
|
+
JOB_QUEUE: Queue;
|
|
79
|
+
ASSETS: { fetch: (request: Request | string) => Promise<Response> };
|
|
80
|
+
APP_SK: string;
|
|
81
|
+
APP_URL: string;
|
|
82
|
+
APP_NAME: string;
|
|
83
|
+
APP_PID: string;
|
|
84
|
+
COMPONENT_DID: string;
|
|
85
|
+
MEDIA_KIT_URL: string;
|
|
86
|
+
MEDIA_KIT: { fetch: (request: Request | string) => Promise<Response> };
|
|
87
|
+
AUTH_SERVICE: {
|
|
88
|
+
fetch: (request: Request | string) => Promise<Response>;
|
|
89
|
+
resolveIdentity: (
|
|
90
|
+
jwt: string | null,
|
|
91
|
+
authorizationHeader: string | null,
|
|
92
|
+
instanceDid?: string
|
|
93
|
+
) => Promise<CallerIdentityDTO | null>;
|
|
94
|
+
verify: (jwt: string) => Promise<CallerIdentityDTO | null>;
|
|
95
|
+
verifyFull: (jwt: string) => Promise<CallerIdentityDTO | null>;
|
|
96
|
+
getUserByDid: (did: string) => Promise<{
|
|
97
|
+
did: string;
|
|
98
|
+
pk: string;
|
|
99
|
+
fullName?: string;
|
|
100
|
+
email?: string;
|
|
101
|
+
avatar?: string;
|
|
102
|
+
role?: string;
|
|
103
|
+
approved?: number;
|
|
104
|
+
} | null>;
|
|
105
|
+
};
|
|
106
|
+
HYPERDRIVE: { connectionString: string };
|
|
107
|
+
[key: string]: any;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// === JWT identity cache — avoid repeated AUTH_SERVICE RPC for the same token ===
|
|
111
|
+
const JWT_CACHE_MAX_SIZE = 1000;
|
|
112
|
+
const JWT_CACHE_DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes fallback
|
|
113
|
+
const jwtIdentityCache = new Map<string, { identity: CallerIdentityDTO; expiresAt: number }>();
|
|
114
|
+
|
|
115
|
+
function getJwtExpiry(jwt: string): number | null {
|
|
116
|
+
try {
|
|
117
|
+
const parts = jwt.split('.');
|
|
118
|
+
if (parts.length !== 3) return null;
|
|
119
|
+
// Base64url decode the payload
|
|
120
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
121
|
+
const decoded = JSON.parse(atob(payload));
|
|
122
|
+
if (typeof decoded.exp === 'number') {
|
|
123
|
+
return decoded.exp * 1000; // convert seconds to ms
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Fall through — use default TTL
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getCachedIdentity(jwt: string): CallerIdentityDTO | null {
|
|
132
|
+
const entry = jwtIdentityCache.get(jwt);
|
|
133
|
+
if (!entry) return null;
|
|
134
|
+
if (Date.now() >= entry.expiresAt) {
|
|
135
|
+
jwtIdentityCache.delete(jwt);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return entry.identity;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cacheIdentity(jwt: string, identity: CallerIdentityDTO): void {
|
|
142
|
+
// Evict oldest entries if at capacity
|
|
143
|
+
if (jwtIdentityCache.size >= JWT_CACHE_MAX_SIZE) {
|
|
144
|
+
// Delete the first (oldest inserted) entry
|
|
145
|
+
const firstKey = jwtIdentityCache.keys().next().value;
|
|
146
|
+
if (firstKey) jwtIdentityCache.delete(firstKey);
|
|
147
|
+
}
|
|
148
|
+
const expiresAt = getJwtExpiry(jwt) ?? Date.now() + JWT_CACHE_DEFAULT_TTL_MS;
|
|
149
|
+
jwtIdentityCache.set(jwt, { identity, expiresAt });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// === User-profile-by-DID cache — list pages hit this per row ===
|
|
153
|
+
// NOTE: never include email / phone / role / approved here. The endpoint that consumes this
|
|
154
|
+
// cache is unauthenticated and public — emitting PII would let anyone with a DID enumerate it.
|
|
155
|
+
interface UserProfilePayload {
|
|
156
|
+
did: string;
|
|
157
|
+
fullName: string;
|
|
158
|
+
avatar: string;
|
|
159
|
+
}
|
|
160
|
+
const USER_PROFILE_CACHE_MAX = 500;
|
|
161
|
+
const POSITIVE_USER_TTL_MS = 5 * 60 * 1000;
|
|
162
|
+
const NEGATIVE_USER_TTL_MS = 60 * 1000;
|
|
163
|
+
const userProfileCache = new Map<string, { payload: UserProfilePayload; expiresAt: number }>();
|
|
164
|
+
|
|
165
|
+
function getCachedUserProfile(did: string): UserProfilePayload | null {
|
|
166
|
+
const entry = userProfileCache.get(did);
|
|
167
|
+
if (!entry) return null;
|
|
168
|
+
if (Date.now() >= entry.expiresAt) {
|
|
169
|
+
userProfileCache.delete(did);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return entry.payload;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cacheUserProfile(did: string, payload: UserProfilePayload, ttlMs: number): void {
|
|
176
|
+
if (userProfileCache.size >= USER_PROFILE_CACHE_MAX) {
|
|
177
|
+
const firstKey = userProfileCache.keys().next().value;
|
|
178
|
+
if (firstKey) userProfileCache.delete(firstKey);
|
|
179
|
+
}
|
|
180
|
+
userProfileCache.set(did, { payload, expiresAt: Date.now() + ttlMs });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Initialize D1 + env + models on every request
|
|
184
|
+
let modelsInitialized = false;
|
|
185
|
+
let cronsInitialized = false;
|
|
186
|
+
|
|
187
|
+
function ensureModelsInit() {
|
|
188
|
+
if (!modelsInitialized) {
|
|
189
|
+
try {
|
|
190
|
+
const seq = new Sequelize();
|
|
191
|
+
initialize(seq);
|
|
192
|
+
modelsInitialized = true;
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error('Model init error (non-fatal):', e);
|
|
195
|
+
modelsInitialized = true; // don't retry
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ensureCronsInit() {
|
|
201
|
+
if (!cronsInitialized) {
|
|
202
|
+
try {
|
|
203
|
+
crons.init();
|
|
204
|
+
cronsInitialized = true;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error('Cron init error (non-fatal):', e);
|
|
207
|
+
cronsInitialized = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Security initialization: fetch EK from AUTH_SERVICE, pre-decrypt all encrypted DB values
|
|
213
|
+
let securityInitialized = false;
|
|
214
|
+
|
|
215
|
+
// === Build Hono app ===
|
|
216
|
+
// We use a factory function so DID Connect routes (which need env bindings)
|
|
217
|
+
// are registered in the correct order, before catch-all routes.
|
|
218
|
+
|
|
219
|
+
type HonoEnv = { Bindings: Env; Variables: { caller: CallerIdentityDTO | null } };
|
|
220
|
+
|
|
221
|
+
let cachedApp: Hono<HonoEnv> | null = null;
|
|
222
|
+
let cachedAppSK: string | null = null;
|
|
223
|
+
|
|
224
|
+
function buildApp(env: Env): Hono<HonoEnv> {
|
|
225
|
+
// Reuse cached app within the same isolate if env hasn't changed
|
|
226
|
+
if (cachedApp && cachedAppSK === (env.APP_SK || '')) {
|
|
227
|
+
return cachedApp;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const app = new Hono<HonoEnv>();
|
|
231
|
+
|
|
232
|
+
// CORS
|
|
233
|
+
app.use('/api/*', cors());
|
|
234
|
+
app.use('/.well-known/*', cors());
|
|
235
|
+
// /__blocklet__.js is fetched by external wallets (e.g. abtwallet.io, localhost
|
|
236
|
+
// dev wallets) to resolve app metadata + chain info before starting DID Connect.
|
|
237
|
+
// Without CORS the browser preflight fails and DID Connect cannot proceed.
|
|
238
|
+
app.use('/__blocklet__.js', cors());
|
|
239
|
+
app.use('*/__blocklet__.js', cors());
|
|
240
|
+
|
|
241
|
+
// Set up env + DB + queue on every request
|
|
242
|
+
app.use('*', async (c, next) => {
|
|
243
|
+
if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
|
|
244
|
+
(globalThis as any).__flushDeferredTimers();
|
|
245
|
+
}
|
|
246
|
+
(globalThis as any).__CF_ENV__ = c.env;
|
|
247
|
+
// Flag: HTTP request context. createEvent uses waitUntil (non-blocking).
|
|
248
|
+
// In queue consumer/cron, this flag is absent — createEvent uses __cfPendingJobs__ (blocking).
|
|
249
|
+
(globalThis as any).__cfHttpContext__ = true;
|
|
250
|
+
setDB(withD1Retry(c.env.DB.withSession('first-primary')));
|
|
251
|
+
if (c.env.JOB_QUEUE) setCFQueue(c.env.JOB_QUEUE);
|
|
252
|
+
ensureModelsInit();
|
|
253
|
+
|
|
254
|
+
// Sync CF env vars to process.env for source code compatibility
|
|
255
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
256
|
+
// Stripe webhook secret: kept as env var override for webhook signature verification
|
|
257
|
+
// (DB value may be empty if not configured in original Blocklet Server)
|
|
258
|
+
if (c.env.STRIPE_WEBHOOK_SECRET) process.env.STRIPE_WEBHOOK_SECRET = c.env.STRIPE_WEBHOOK_SECRET;
|
|
259
|
+
if (c.env.APP_URL) {
|
|
260
|
+
process.env.APP_URL = c.env.APP_URL;
|
|
261
|
+
process.env.BLOCKLET_APP_URL = c.env.APP_URL;
|
|
262
|
+
}
|
|
263
|
+
if (c.env.APP_PID) {
|
|
264
|
+
process.env.BLOCKLET_APP_PID = c.env.APP_PID;
|
|
265
|
+
process.env.BLOCKLET_APP_ID = c.env.APP_PID;
|
|
266
|
+
}
|
|
267
|
+
if (c.env.APP_NAME) process.env.BLOCKLET_APP_NAME = c.env.APP_NAME;
|
|
268
|
+
if (c.env.PAYMENT_CHANGE_LOCKED_PRICE) process.env.PAYMENT_CHANGE_LOCKED_PRICE = c.env.PAYMENT_CHANGE_LOCKED_PRICE;
|
|
269
|
+
if (c.env.SHORT_URL_DOMAIN) process.env.SHORT_URL_DOMAIN = c.env.SHORT_URL_DOMAIN;
|
|
270
|
+
process.env.BLOCKLET_MODE = 'production';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Register Stripe key decrypt overrides from env vars
|
|
274
|
+
// Fetch EK from AUTH_SERVICE and initialize decrypt capability (first request only)
|
|
275
|
+
if (!securityInitialized) {
|
|
276
|
+
securityInitialized = true;
|
|
277
|
+
await initFromAuthService(c.env);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await next();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// === Server-Timing: track total API time + auth time ===
|
|
284
|
+
app.use('/api/*', async (c, next) => {
|
|
285
|
+
const t0 = performance.now();
|
|
286
|
+
|
|
287
|
+
// --- Auth ---
|
|
288
|
+
const authT0 = performance.now();
|
|
289
|
+
let authSource = 'none';
|
|
290
|
+
const authService = c.env.AUTH_SERVICE;
|
|
291
|
+
if (authService && typeof authService.resolveIdentity === 'function') {
|
|
292
|
+
try {
|
|
293
|
+
const cookieHeader = c.req.header('Cookie') || '';
|
|
294
|
+
const match = cookieHeader.match(/(?:^|;\s*)login_token=([^;]*)/);
|
|
295
|
+
const jwt = match ? decodeURIComponent(match[1]) : null;
|
|
296
|
+
const authHeader = c.req.header('Authorization') || null;
|
|
297
|
+
|
|
298
|
+
const cacheKey = jwt || authHeader;
|
|
299
|
+
let caller: CallerIdentityDTO | null = null;
|
|
300
|
+
if (cacheKey) {
|
|
301
|
+
caller = getCachedIdentity(cacheKey);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (caller) {
|
|
305
|
+
authSource = 'cache';
|
|
306
|
+
} else {
|
|
307
|
+
caller = await authService.resolveIdentity(jwt, authHeader, c.env.APP_PID);
|
|
308
|
+
authSource = 'rpc';
|
|
309
|
+
if (caller && cacheKey) {
|
|
310
|
+
cacheIdentity(cacheKey, caller);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
c.set('caller', caller);
|
|
315
|
+
} catch (e: any) {
|
|
316
|
+
console.error('[Auth] resolveIdentity error:', e?.message || e);
|
|
317
|
+
c.set('caller', null);
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
c.set('caller', null);
|
|
321
|
+
}
|
|
322
|
+
const authDur = Math.round(performance.now() - authT0);
|
|
323
|
+
|
|
324
|
+
// --- Route handler ---
|
|
325
|
+
resetD1Timing();
|
|
326
|
+
await next();
|
|
327
|
+
|
|
328
|
+
// --- Append Server-Timing header ---
|
|
329
|
+
const totalDur = Math.round(performance.now() - t0);
|
|
330
|
+
const d1 = getD1Timing();
|
|
331
|
+
const timings = [
|
|
332
|
+
`total;dur=${totalDur}`,
|
|
333
|
+
`auth;dur=${authDur};desc="${authSource}"`,
|
|
334
|
+
];
|
|
335
|
+
if (d1.queries > 0) {
|
|
336
|
+
timings.push(`db;dur=${Math.round(d1.wallMs)};desc="${d1.queries}q ${d1.rowsRead}r"`);
|
|
337
|
+
if (d1.sqlMs > 0) timings.push(`db_sql;dur=${Math.round(d1.sqlMs)}`);
|
|
338
|
+
}
|
|
339
|
+
// Named phases (e.g. chain, cache, ext_rpc) from measurePhase() calls
|
|
340
|
+
for (const [name, dur] of Object.entries(d1.phases || {})) {
|
|
341
|
+
timings.push(`${name};dur=${Math.round(dur)}`);
|
|
342
|
+
}
|
|
343
|
+
c.res.headers.append('Server-Timing', timings.join(', '));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Health check
|
|
347
|
+
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
348
|
+
|
|
349
|
+
// user-session under /.well-known/service — proxy to blocklet-service session API
|
|
350
|
+
// Returns full session data including connectedAccounts (needed by @arcblock/ux SessionUser)
|
|
351
|
+
app.get('/.well-known/service/api/user-session', async (c) => {
|
|
352
|
+
if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
|
|
353
|
+
const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
|
|
354
|
+
if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
|
|
355
|
+
const resp = await c.env.AUTH_SERVICE.fetch(req);
|
|
356
|
+
return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// user-sessions (plural) — multi-device session list, not implemented in blocklet-service
|
|
360
|
+
app.get('/.well-known/service/api/user-sessions', (c) => c.json([]));
|
|
361
|
+
|
|
362
|
+
// Public user info by DID — resolves via AUTH_SERVICE.getUserByDid RPC.
|
|
363
|
+
// PRIVACY: this endpoint is unauthenticated (anyone who knows a DID can call it),
|
|
364
|
+
// so the response MUST NOT contain email / phone / role / approved or any other PII.
|
|
365
|
+
// Admin-only pages that need email should fetch it via authenticated customer APIs.
|
|
366
|
+
// UserCard renders rows by DID and also caches in sessionStorage on the client;
|
|
367
|
+
// a small isolate-level cache absorbs burst traffic within the same request wave.
|
|
368
|
+
app.get('/.well-known/service/api/user', async (c) => {
|
|
369
|
+
const did = c.req.query('did') || '';
|
|
370
|
+
if (!did) return c.json({ did: '', fullName: '', avatar: '' });
|
|
371
|
+
|
|
372
|
+
const emptyResponse = () => c.json({ did, fullName: '', avatar: '' });
|
|
373
|
+
|
|
374
|
+
const cached = getCachedUserProfile(did);
|
|
375
|
+
if (cached) return c.json(cached);
|
|
376
|
+
|
|
377
|
+
const authService = c.env.AUTH_SERVICE;
|
|
378
|
+
if (!authService || typeof authService.getUserByDid !== 'function') {
|
|
379
|
+
return emptyResponse();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const user = await authService.getUserByDid(did);
|
|
384
|
+
if (!user) {
|
|
385
|
+
cacheUserProfile(did, { did, fullName: '', avatar: '' }, NEGATIVE_USER_TTL_MS);
|
|
386
|
+
return emptyResponse();
|
|
387
|
+
}
|
|
388
|
+
// Normalize avatar: keep inline data URIs (browsers render them directly), drop raw external
|
|
389
|
+
// URLs (lh3.googleusercontent.com etc. fail under our CSP/hotlink rules, and UserCard's
|
|
390
|
+
// useProxyFallback relies on a `/.well-known/service/proxy` route that isn't implemented
|
|
391
|
+
// in the Workers build). Default to the same-origin avatar endpoint, which always returns
|
|
392
|
+
// a deterministic SVG fallback.
|
|
393
|
+
const rawAvatar = user.avatar || '';
|
|
394
|
+
const normalizedAvatar = rawAvatar.startsWith('data:')
|
|
395
|
+
? rawAvatar
|
|
396
|
+
: `/.well-known/service/user/avatar/${user.did}`;
|
|
397
|
+
const payload = {
|
|
398
|
+
did: user.did,
|
|
399
|
+
fullName: user.fullName || '',
|
|
400
|
+
avatar: normalizedAvatar,
|
|
401
|
+
};
|
|
402
|
+
cacheUserProfile(did, payload, POSITIVE_USER_TTL_MS);
|
|
403
|
+
return c.json(payload);
|
|
404
|
+
} catch (e: any) {
|
|
405
|
+
console.error('[user-by-did] RPC error:', e?.message || e);
|
|
406
|
+
return emptyResponse();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// === DID Auth Service — forward all /.well-known/service/* to AUTH_SERVICE ===
|
|
411
|
+
// This gives payment-kit: real login (passkey/DID wallet), dynamic branding,
|
|
412
|
+
// theme, admin panel, user management — all from the shared DID service.
|
|
413
|
+
app.all('/.well-known/service/*', async (c) => {
|
|
414
|
+
if (c.env.AUTH_SERVICE) {
|
|
415
|
+
// Add X-Instance-Did header so blocklet-service uses registered instance keys
|
|
416
|
+
const req = new Request(c.req.raw);
|
|
417
|
+
if (c.env.APP_PID) {
|
|
418
|
+
req.headers.set('X-Instance-Did', c.env.APP_PID);
|
|
419
|
+
}
|
|
420
|
+
// Pass external tabs for admin/user page integration
|
|
421
|
+
req.headers.set(
|
|
422
|
+
'X-External-Tabs',
|
|
423
|
+
JSON.stringify([{ id: 'billing', label: 'Billing', labels: { en: 'Billing', zh: '账单' }, url: '/customer' }])
|
|
424
|
+
);
|
|
425
|
+
const resp = await c.env.AUTH_SERVICE.fetch(req);
|
|
426
|
+
// Re-create Response to ensure Set-Cookie and other headers are forwarded to the browser
|
|
427
|
+
return new Response(resp.body, {
|
|
428
|
+
status: resp.status,
|
|
429
|
+
statusText: resp.statusText,
|
|
430
|
+
headers: new Headers(resp.headers),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Fallback: no AUTH_SERVICE binding (local dev without service binding)
|
|
434
|
+
return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// __blocklet__.js — forward to AUTH_SERVICE, then override payment-kit specific fields
|
|
438
|
+
// Supports ?type=json for JSON output, respects Cache-Control: private, no-store
|
|
439
|
+
// Register for both root /__blocklet__.js and prefixed /x/__blocklet__.js
|
|
440
|
+
for (const pattern of ['/__blocklet__.js', '*/__blocklet__.js'] as const) {
|
|
441
|
+
app.get(pattern, async (c) => {
|
|
442
|
+
const isJson = new URL(c.req.url).searchParams.get('type') === 'json';
|
|
443
|
+
|
|
444
|
+
if (c.env.AUTH_SERVICE) {
|
|
445
|
+
const url = new URL(c.req.url);
|
|
446
|
+
// Always fetch JSON from AUTH_SERVICE for reliable parsing
|
|
447
|
+
url.pathname = '/__blocklet__.js';
|
|
448
|
+
url.searchParams.set('type', 'json');
|
|
449
|
+
const blockletReq = new Request(url.toString(), c.req.raw);
|
|
450
|
+
if (c.env.APP_PID) {
|
|
451
|
+
blockletReq.headers.set('X-Instance-Did', c.env.APP_PID);
|
|
452
|
+
}
|
|
453
|
+
const resp = await c.env.AUTH_SERVICE.fetch(blockletReq);
|
|
454
|
+
try {
|
|
455
|
+
// AUTH_SERVICE may return JSON or JavaScript (`window.blocklet = {...};`)
|
|
456
|
+
// depending on whether the ?type=json param is honored. Handle both by
|
|
457
|
+
// extracting the JSON object literal from the response text.
|
|
458
|
+
const respText = await resp.text();
|
|
459
|
+
let data: Record<string, unknown>;
|
|
460
|
+
try {
|
|
461
|
+
data = JSON.parse(respText);
|
|
462
|
+
} catch {
|
|
463
|
+
const start = respText.indexOf('{');
|
|
464
|
+
const end = respText.lastIndexOf('}') + 1;
|
|
465
|
+
if (start < 0 || end <= start) {
|
|
466
|
+
throw new Error('AUTH_SERVICE __blocklet__.js response has no JSON payload');
|
|
467
|
+
}
|
|
468
|
+
data = JSON.parse(respText.slice(start, end));
|
|
469
|
+
}
|
|
470
|
+
// Override payment-kit specific fields
|
|
471
|
+
data.appPid = c.env.APP_PID || data.appPid;
|
|
472
|
+
data.componentId = c.env.COMPONENT_DID || data.componentId;
|
|
473
|
+
data.appUrl = c.env.APP_URL || new URL(c.req.url).origin;
|
|
474
|
+
data.cloudflareWorker = true;
|
|
475
|
+
|
|
476
|
+
// Mount prefix support: gateway passes X-Mount-Prefix header
|
|
477
|
+
const mountPrefix = c.req.header('X-Mount-Prefix');
|
|
478
|
+
if (mountPrefix) {
|
|
479
|
+
data.prefix = mountPrefix;
|
|
480
|
+
data.groupPrefix = mountPrefix;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Append Payment Kit component mount points for admin panel integration
|
|
484
|
+
const existingMounts = Array.isArray(data.componentMountPoints) ? (data.componentMountPoints as any[]) : [];
|
|
485
|
+
data.componentMountPoints = [
|
|
486
|
+
...existingMounts,
|
|
487
|
+
{
|
|
488
|
+
did: c.env.COMPONENT_DID || 'payment-kit',
|
|
489
|
+
title: 'My Account',
|
|
490
|
+
name: 'customer',
|
|
491
|
+
mountPoint: '/customer',
|
|
492
|
+
},
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
if (isJson) {
|
|
496
|
+
return new Response(JSON.stringify(data), {
|
|
497
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' },
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return new Response(`window.blocklet = ${JSON.stringify(data)};`, {
|
|
501
|
+
headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'private, no-store' },
|
|
502
|
+
});
|
|
503
|
+
} catch {
|
|
504
|
+
// Fallback: return AUTH_SERVICE response as-is
|
|
505
|
+
return resp;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const script = `window.blocklet = { appName: '${c.env.APP_NAME || 'Payment Kit'}', appUrl: '${c.env.APP_URL || ''}', appPid: '${c.env.APP_PID || ''}', theme: {prefer:'light'}, cloudflareWorker: true };`;
|
|
509
|
+
return new Response(script, {
|
|
510
|
+
headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'private, no-store' },
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// favicon.ico — forward to AUTH_SERVICE (serves from R2 or default SVG)
|
|
516
|
+
app.get('/favicon.ico', async (c) => {
|
|
517
|
+
if (c.env.AUTH_SERVICE) {
|
|
518
|
+
return c.env.AUTH_SERVICE.fetch(c.req.raw);
|
|
519
|
+
}
|
|
520
|
+
return new Response(null, { status: 404 });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Session endpoints — resolve via AUTH_SERVICE RPC
|
|
524
|
+
// /api/user-session — proxy to blocklet-service session API (same as /.well-known/service/api/user-session)
|
|
525
|
+
app.get('/api/user-session', async (c) => {
|
|
526
|
+
if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
|
|
527
|
+
const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
|
|
528
|
+
if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
|
|
529
|
+
const resp = await c.env.AUTH_SERVICE.fetch(req);
|
|
530
|
+
return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Debug endpoints
|
|
534
|
+
app.get('/api/__debug__/time', (c) => c.json({ now: Date.now(), floor: Math.floor(Date.now() / 1000) }));
|
|
535
|
+
app.get('/api/__debug__/sk-check', (c) => {
|
|
536
|
+
const sk = c.env.APP_SK || '';
|
|
537
|
+
return c.json({
|
|
538
|
+
length: sk.length,
|
|
539
|
+
prefix: sk.substring(0, 6),
|
|
540
|
+
isHex: /^[0-9a-fA-F]+$/.test(sk),
|
|
541
|
+
startsWithZ: sk.startsWith('z'),
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// === DID Auth Login routes ===
|
|
546
|
+
// Only proxy login-related /api/did/* paths to blocklet-service.
|
|
547
|
+
// Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's
|
|
548
|
+
// own DID Connect actions handled by attachDIDConnectRoutes below.
|
|
549
|
+
const DID_AUTH_PROXY_PATHS = [
|
|
550
|
+
'/api/did/login/',
|
|
551
|
+
'/api/did/session',
|
|
552
|
+
'/api/did/refreshSession',
|
|
553
|
+
'/api/did/connect/',
|
|
554
|
+
'/api/did/logout',
|
|
555
|
+
];
|
|
556
|
+
app.all('/api/did/*', async (c, next) => {
|
|
557
|
+
const path = new URL(c.req.url).pathname;
|
|
558
|
+
const shouldProxy = DID_AUTH_PROXY_PATHS.some((p) => path.startsWith(p) || path === p);
|
|
559
|
+
if (!shouldProxy) return next(); // Fall through to attachDIDConnectRoutes or Express routes
|
|
560
|
+
|
|
561
|
+
if (!c.env.AUTH_SERVICE) {
|
|
562
|
+
return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
|
|
563
|
+
}
|
|
564
|
+
const url = new URL(c.req.url);
|
|
565
|
+
url.pathname = `/.well-known/service${url.pathname}`;
|
|
566
|
+
const req = new Request(url.toString(), c.req.raw);
|
|
567
|
+
if (c.env.APP_PID) {
|
|
568
|
+
req.headers.set('X-Instance-Did', c.env.APP_PID);
|
|
569
|
+
}
|
|
570
|
+
const resp = await c.env.AUTH_SERVICE.fetch(req);
|
|
571
|
+
const headers = new Headers(resp.headers);
|
|
572
|
+
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// === DID Connect business actions (subscription, pay, collect, etc.) ===
|
|
576
|
+
if (env.APP_SK && env.DID_CONNECT_KV) {
|
|
577
|
+
try {
|
|
578
|
+
attachDIDConnectRoutes(app, env.DID_CONNECT_KV, env.APP_SK);
|
|
579
|
+
console.log('[CF Worker] DID Connect routes attached');
|
|
580
|
+
} catch (e: any) {
|
|
581
|
+
console.error('DID Connect init error:', e?.message || e);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Notification unread count
|
|
586
|
+
app.get('/api/notifications/unread-count', (c) => c.json({ unReadCount: 0 }));
|
|
587
|
+
|
|
588
|
+
// Manually trigger job dispatch (same as cron's runAllScheduledJobs)
|
|
589
|
+
app.post('/api/__dev__/dispatch-jobs', async (c) => {
|
|
590
|
+
const names = getAllHandlerNames();
|
|
591
|
+
const result = await runAllScheduledJobs();
|
|
592
|
+
return c.json({ handlers: names, ...result });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// === Express-to-Hono Route Adapter ===
|
|
596
|
+
mountExpressRoutes(app, '/api', expressRoutes);
|
|
597
|
+
|
|
598
|
+
// Dev endpoint: D1 admin operations
|
|
599
|
+
// Test CF Queue send directly
|
|
600
|
+
app.get('/api/__dev__/queue-test', async (c) => {
|
|
601
|
+
const queue = c.env.JOB_QUEUE;
|
|
602
|
+
if (!queue) return c.json({ error: 'JOB_QUEUE not bound' }, 500);
|
|
603
|
+
try {
|
|
604
|
+
const t = Date.now();
|
|
605
|
+
await queue.send({ test: true, ts: t });
|
|
606
|
+
return c.json({ success: true, ms: Date.now() - t });
|
|
607
|
+
} catch (err: any) {
|
|
608
|
+
return c.json({ error: err?.message, name: err?.name, code: err?.code }, 500);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
app.post('/api/__dev__/d1-exec', async (c) => {
|
|
613
|
+
const { sql } = (await c.req.json()) as { sql: string };
|
|
614
|
+
if (!sql) return c.json({ error: 'sql required' }, 400);
|
|
615
|
+
const db = c.env.DB;
|
|
616
|
+
const result = await db.prepare(sql).all();
|
|
617
|
+
return c.json({ success: true, results: result.results, meta: result.meta });
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Dev endpoint: comprehensive DB benchmark (D1 + remote PostgreSQL RTT)
|
|
621
|
+
app.get('/api/__dev__/benchmark-db', async (c) => {
|
|
622
|
+
const db = c.env.DB;
|
|
623
|
+
const results: Record<string, any> = { d1: {}, postgres: {}, comparison: {} };
|
|
624
|
+
|
|
625
|
+
// === D1 Benchmark ===
|
|
626
|
+
// Warm-up
|
|
627
|
+
await db.prepare('SELECT 1').first();
|
|
628
|
+
|
|
629
|
+
// 1. Single SELECT
|
|
630
|
+
let t = Date.now();
|
|
631
|
+
await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
|
|
632
|
+
results.d1.single_select_ms = Date.now() - t;
|
|
633
|
+
|
|
634
|
+
// 2. Single INSERT + SELECT batch (simulate Model.create)
|
|
635
|
+
const testId = `bench_${Date.now()}`;
|
|
636
|
+
t = Date.now();
|
|
637
|
+
await db.batch([
|
|
638
|
+
db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
|
|
639
|
+
db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
|
|
640
|
+
]);
|
|
641
|
+
results.d1.insert_select_batch_ms = Date.now() - t;
|
|
642
|
+
await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();
|
|
643
|
+
|
|
644
|
+
// 3. 5x sequential SELECTs (simulate N+1 queries)
|
|
645
|
+
t = Date.now();
|
|
646
|
+
for (let i = 0; i < 5; i++) {
|
|
647
|
+
await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
|
|
648
|
+
}
|
|
649
|
+
results.d1.sequential_5x_ms = Date.now() - t;
|
|
650
|
+
results.d1.sequential_5x_avg_ms = Math.round(results.d1.sequential_5x_ms / 5);
|
|
651
|
+
|
|
652
|
+
// 4. 5x batch (one D1 round-trip)
|
|
653
|
+
t = Date.now();
|
|
654
|
+
await db.batch([
|
|
655
|
+
db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
|
|
656
|
+
db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
|
|
657
|
+
db.prepare('SELECT id, status FROM invoices LIMIT 1'),
|
|
658
|
+
db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
|
|
659
|
+
db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
|
|
660
|
+
]);
|
|
661
|
+
results.d1.batch_5x_ms = Date.now() - t;
|
|
662
|
+
|
|
663
|
+
// 5. 10x sequential
|
|
664
|
+
t = Date.now();
|
|
665
|
+
for (let i = 0; i < 10; i++) {
|
|
666
|
+
await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
|
|
667
|
+
}
|
|
668
|
+
results.d1.sequential_10x_ms = Date.now() - t;
|
|
669
|
+
results.d1.sequential_10x_avg_ms = Math.round(results.d1.sequential_10x_ms / 10);
|
|
670
|
+
|
|
671
|
+
// 6. 10x batch
|
|
672
|
+
t = Date.now();
|
|
673
|
+
await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
|
|
674
|
+
results.d1.batch_10x_ms = Date.now() - t;
|
|
675
|
+
|
|
676
|
+
// 7. UPDATE RETURNING (simulate optimized getInvoiceNumber)
|
|
677
|
+
t = Date.now();
|
|
678
|
+
await db
|
|
679
|
+
.prepare(
|
|
680
|
+
'UPDATE _locks SET expires_at = expires_at + 1 WHERE name = (SELECT name FROM _locks LIMIT 1) RETURNING *'
|
|
681
|
+
)
|
|
682
|
+
.run();
|
|
683
|
+
results.d1.update_returning_ms = Date.now() - t;
|
|
684
|
+
|
|
685
|
+
// 8. Simulate full getInvoiceNumber (old): reload + increment(UPDATE + reload)
|
|
686
|
+
// Using _locks as proxy table
|
|
687
|
+
const lockRow = await db.prepare('SELECT name FROM _locks LIMIT 1').first();
|
|
688
|
+
if (lockRow) {
|
|
689
|
+
t = Date.now();
|
|
690
|
+
await db
|
|
691
|
+
.prepare('SELECT * FROM _locks WHERE name = ?')
|
|
692
|
+
.bind((lockRow as any).name)
|
|
693
|
+
.first(); // reload
|
|
694
|
+
await db
|
|
695
|
+
.prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ?')
|
|
696
|
+
.bind((lockRow as any).name)
|
|
697
|
+
.run(); // update
|
|
698
|
+
await db
|
|
699
|
+
.prepare('SELECT * FROM _locks WHERE name = ?')
|
|
700
|
+
.bind((lockRow as any).name)
|
|
701
|
+
.first(); // reload after increment
|
|
702
|
+
results.d1.getInvoiceNumber_old_3RT_ms = Date.now() - t;
|
|
703
|
+
|
|
704
|
+
t = Date.now();
|
|
705
|
+
await db
|
|
706
|
+
.prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ? RETURNING expires_at - 1 as prev')
|
|
707
|
+
.bind((lockRow as any).name)
|
|
708
|
+
.first();
|
|
709
|
+
results.d1.getInvoiceNumber_new_1RT_ms = Date.now() - t;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// === PostgreSQL via Hyperdrive benchmark ===
|
|
713
|
+
// Try Hyperdrive first, then direct DATABASE_URL
|
|
714
|
+
const pgConnections = [
|
|
715
|
+
{ name: 'hyperdrive', connStr: c.env.HYPERDRIVE?.connectionString },
|
|
716
|
+
{ name: 'direct', connStr: c.env.DATABASE_URL },
|
|
717
|
+
].filter((x) => x.connStr);
|
|
718
|
+
|
|
719
|
+
for (const pg of pgConnections) {
|
|
720
|
+
const pgResult: Record<string, any> = {};
|
|
721
|
+
results.postgres[pg.name] = pgResult;
|
|
722
|
+
pgResult.connection_string = pg.connStr!.replace(/:[^:@]+@/, ':***@');
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
// Connect with timeout
|
|
726
|
+
t = Date.now();
|
|
727
|
+
const client = new PgClient({
|
|
728
|
+
connectionString: pg.connStr,
|
|
729
|
+
connectionTimeoutMillis: 5000,
|
|
730
|
+
ssl: pg.name === 'direct' ? { rejectUnauthorized: false } : undefined,
|
|
731
|
+
});
|
|
732
|
+
await client.connect();
|
|
733
|
+
pgResult.connect_ms = Date.now() - t;
|
|
734
|
+
|
|
735
|
+
// Warm-up
|
|
736
|
+
await client.query('SELECT 1');
|
|
737
|
+
|
|
738
|
+
// 1. Single SELECT
|
|
739
|
+
t = Date.now();
|
|
740
|
+
const r1 = await client.query('SELECT 1 as test');
|
|
741
|
+
pgResult.single_select_ms = Date.now() - t;
|
|
742
|
+
pgResult.single_select_result = r1.rows?.[0];
|
|
743
|
+
|
|
744
|
+
// 2. 5x sequential SELECTs
|
|
745
|
+
t = Date.now();
|
|
746
|
+
for (let i = 0; i < 5; i++) {
|
|
747
|
+
await client.query('SELECT 1 as test');
|
|
748
|
+
}
|
|
749
|
+
pgResult.sequential_5x_ms = Date.now() - t;
|
|
750
|
+
pgResult.sequential_5x_avg_ms = Math.round(pgResult.sequential_5x_ms / 5);
|
|
751
|
+
|
|
752
|
+
// 3. 10x sequential SELECTs
|
|
753
|
+
t = Date.now();
|
|
754
|
+
for (let i = 0; i < 10; i++) {
|
|
755
|
+
await client.query('SELECT 1 as test');
|
|
756
|
+
}
|
|
757
|
+
pgResult.sequential_10x_ms = Date.now() - t;
|
|
758
|
+
pgResult.sequential_10x_avg_ms = Math.round(pgResult.sequential_10x_ms / 10);
|
|
759
|
+
|
|
760
|
+
await client.end();
|
|
761
|
+
} catch (err: any) {
|
|
762
|
+
pgResult.error = err?.message || 'Failed';
|
|
763
|
+
pgResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// === postgres.js driver test (alternative to pg) ===
|
|
768
|
+
const pgJsConnStr = c.env.HYPERDRIVE?.connectionString || c.env.DATABASE_URL;
|
|
769
|
+
if (pgJsConnStr) {
|
|
770
|
+
const pgJsResult: Record<string, any> = {};
|
|
771
|
+
results.postgres.postgres_js = pgJsResult;
|
|
772
|
+
try {
|
|
773
|
+
t = Date.now();
|
|
774
|
+
const sql = postgres(pgJsConnStr, { ssl: 'require', connect_timeout: 5, idle_timeout: 5 });
|
|
775
|
+
await sql`SELECT 1 as test`;
|
|
776
|
+
pgJsResult.connect_and_warmup_ms = Date.now() - t;
|
|
777
|
+
|
|
778
|
+
t = Date.now();
|
|
779
|
+
const r = await sql`SELECT 1 as test`;
|
|
780
|
+
pgJsResult.single_select_ms = Date.now() - t;
|
|
781
|
+
pgJsResult.single_select_result = r[0];
|
|
782
|
+
|
|
783
|
+
t = Date.now();
|
|
784
|
+
for (let i = 0; i < 5; i++) await sql`SELECT 1 as test`;
|
|
785
|
+
pgJsResult.sequential_5x_ms = Date.now() - t;
|
|
786
|
+
pgJsResult.sequential_5x_avg_ms = Math.round(pgJsResult.sequential_5x_ms / 5);
|
|
787
|
+
|
|
788
|
+
t = Date.now();
|
|
789
|
+
for (let i = 0; i < 10; i++) await sql`SELECT 1 as test`;
|
|
790
|
+
pgJsResult.sequential_10x_ms = Date.now() - t;
|
|
791
|
+
pgJsResult.sequential_10x_avg_ms = Math.round(pgJsResult.sequential_10x_ms / 10);
|
|
792
|
+
|
|
793
|
+
await sql.end();
|
|
794
|
+
} catch (err: any) {
|
|
795
|
+
pgJsResult.error = err?.message || 'Failed';
|
|
796
|
+
pgJsResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// === Comparison ===
|
|
801
|
+
const hdResult = results.postgres.hyperdrive || {};
|
|
802
|
+
const directResult = results.postgres.direct || {};
|
|
803
|
+
const pgJsR = results.postgres.postgres_js || {};
|
|
804
|
+
const pgSingle = hdResult.single_select_ms || directResult.single_select_ms || pgJsR.single_select_ms;
|
|
805
|
+
const d1Single = results.d1.single_select_ms;
|
|
806
|
+
results.comparison = {
|
|
807
|
+
d1_single_ms: d1Single,
|
|
808
|
+
d1_batch_5x_ms: results.d1.batch_5x_ms,
|
|
809
|
+
d1_batch_5x_avg_ms: Math.round(results.d1.batch_5x_ms / 5),
|
|
810
|
+
pg_single_ms: pgSingle || 'N/A',
|
|
811
|
+
pg_5x_avg_ms: results.postgres.sequential_5x_avg_ms || 'N/A',
|
|
812
|
+
speedup: pgSingle ? `${(d1Single / pgSingle).toFixed(1)}x` : 'N/A',
|
|
813
|
+
conclusion: pgSingle
|
|
814
|
+
? d1Single > pgSingle
|
|
815
|
+
? `PG is ${(d1Single / pgSingle).toFixed(1)}x faster per query (${pgSingle}ms vs ${d1Single}ms). For 15 sequential queries: PG ~${pgSingle * 15}ms vs D1 ~${d1Single * 15}ms`
|
|
816
|
+
: `D1 is faster or comparable (${d1Single}ms vs ${pgSingle}ms)`
|
|
817
|
+
: 'PG benchmark failed — check Hyperdrive config',
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
return c.json(results);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Legacy D1-only benchmark (kept for backward compatibility)
|
|
824
|
+
app.get('/api/__dev__/d1-benchmark', async (c) => {
|
|
825
|
+
const db = c.env.DB;
|
|
826
|
+
const results: Record<string, any> = {};
|
|
827
|
+
|
|
828
|
+
// 1. Single query (warm-up)
|
|
829
|
+
await db.prepare('SELECT 1').first();
|
|
830
|
+
|
|
831
|
+
// 2. Single SELECT RTT
|
|
832
|
+
const t1 = Date.now();
|
|
833
|
+
await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
|
|
834
|
+
results['1_single_select'] = Date.now() - t1;
|
|
835
|
+
|
|
836
|
+
// 3. 5x sequential SELECTs
|
|
837
|
+
const t2 = Date.now();
|
|
838
|
+
for (let i = 0; i < 5; i++) {
|
|
839
|
+
await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
|
|
840
|
+
}
|
|
841
|
+
results['2_sequential_5x'] = Date.now() - t2;
|
|
842
|
+
results['2_sequential_avg'] = Math.round(results['2_sequential_5x'] / 5);
|
|
843
|
+
|
|
844
|
+
// 4. 5x batch (one round-trip)
|
|
845
|
+
const t3 = Date.now();
|
|
846
|
+
await db.batch([
|
|
847
|
+
db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
|
|
848
|
+
db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
|
|
849
|
+
db.prepare('SELECT id, status FROM invoices LIMIT 1'),
|
|
850
|
+
db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
|
|
851
|
+
db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
|
|
852
|
+
]);
|
|
853
|
+
results['3_batch_5x'] = Date.now() - t3;
|
|
854
|
+
|
|
855
|
+
// 5. 5x parallel (Promise.all, separate round-trips)
|
|
856
|
+
const t4 = Date.now();
|
|
857
|
+
await Promise.all([
|
|
858
|
+
db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first(),
|
|
859
|
+
db.prepare('SELECT id, status FROM payment_intents LIMIT 1').first(),
|
|
860
|
+
db.prepare('SELECT id, status FROM invoices LIMIT 1').first(),
|
|
861
|
+
db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1').first(),
|
|
862
|
+
db.prepare('SELECT id, type FROM payment_methods LIMIT 1').first(),
|
|
863
|
+
]);
|
|
864
|
+
results['4_parallel_5x'] = Date.now() - t4;
|
|
865
|
+
|
|
866
|
+
// 6. INSERT + SELECT batch (simulate create)
|
|
867
|
+
const testId = `bench_${Date.now()}`;
|
|
868
|
+
const t5 = Date.now();
|
|
869
|
+
await db.batch([
|
|
870
|
+
db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
|
|
871
|
+
db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
|
|
872
|
+
]);
|
|
873
|
+
results['5_insert_select_batch'] = Date.now() - t5;
|
|
874
|
+
await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();
|
|
875
|
+
|
|
876
|
+
// 7. 10x sequential (simulate ensureInvoiceForCheckout)
|
|
877
|
+
const t6 = Date.now();
|
|
878
|
+
for (let i = 0; i < 10; i++) {
|
|
879
|
+
await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
|
|
880
|
+
}
|
|
881
|
+
results['6_sequential_10x'] = Date.now() - t6;
|
|
882
|
+
results['6_sequential_10x_avg'] = Math.round(results['6_sequential_10x'] / 10);
|
|
883
|
+
|
|
884
|
+
// 8. 10x batch
|
|
885
|
+
const t7 = Date.now();
|
|
886
|
+
await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
|
|
887
|
+
results['7_batch_10x'] = Date.now() - t7;
|
|
888
|
+
|
|
889
|
+
results.summary = {
|
|
890
|
+
single_rtt: results['1_single_select'],
|
|
891
|
+
batch_saves: `${results['2_sequential_5x'] - results['3_batch_5x']}ms for 5 queries`,
|
|
892
|
+
parallel_saves: `${results['2_sequential_5x'] - results['4_parallel_5x']}ms for 5 queries`,
|
|
893
|
+
batch_10x_saves: `${results['6_sequential_10x'] - results['7_batch_10x']}ms for 10 queries`,
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
return c.json(results);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// Dev endpoint to manually trigger cron jobs
|
|
900
|
+
app.post('/api/__dev__/cron/run', async (c) => {
|
|
901
|
+
ensureCronsInit();
|
|
902
|
+
const body = await c.req.json().catch(() => ({}));
|
|
903
|
+
const jobName = (body as any)?.job;
|
|
904
|
+
if (jobName) {
|
|
905
|
+
await cronInstance.runJob(jobName);
|
|
906
|
+
return c.json({ ok: true, ran: jobName });
|
|
907
|
+
}
|
|
908
|
+
await cronInstance.runAll();
|
|
909
|
+
return c.json({ ok: true, ran: 'all', jobs: cronInstance.getJobNames() });
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
app.get('/api/__dev__/cron/jobs', (c) => {
|
|
913
|
+
ensureCronsInit();
|
|
914
|
+
return c.json({ jobs: cronInstance.getJobNames() });
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// Dev endpoint to run raw SQL (for migrations)
|
|
918
|
+
app.get('/api/__dev__/d1/exec', async (c) => {
|
|
919
|
+
const sql = c.req.query('sql');
|
|
920
|
+
if (!sql) return c.json({ error: 'sql query param required' }, 400);
|
|
921
|
+
try {
|
|
922
|
+
const result = await c.env.DB.exec(sql);
|
|
923
|
+
return c.json({ ok: true, result });
|
|
924
|
+
} catch (err: any) {
|
|
925
|
+
return c.json({ error: err?.message || 'exec failed' }, 500);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// Dev endpoint to query D1 (returns rows)
|
|
930
|
+
app.get('/api/__dev__/d1/query', async (c) => {
|
|
931
|
+
const sql = c.req.query('sql');
|
|
932
|
+
if (!sql) return c.json({ error: 'sql query param required' }, 400);
|
|
933
|
+
try {
|
|
934
|
+
const result = await c.env.DB.prepare(sql).all();
|
|
935
|
+
return c.json({ ok: true, results: result.results, meta: result.meta });
|
|
936
|
+
} catch (err: any) {
|
|
937
|
+
return c.json({ error: err?.message || 'query failed' }, 500);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// Catch-all for unimplemented API routes
|
|
942
|
+
app.all('/api/*', (c) => {
|
|
943
|
+
return c.json({ error: `Not yet implemented: ${c.req.method} ${c.req.path}` }, 501);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// === Media Kit Proxy ===
|
|
947
|
+
// CORS preflight for media-kit (must be before app.all)
|
|
948
|
+
app.options('/media-kit/*', (_c) => {
|
|
949
|
+
return new Response(null, {
|
|
950
|
+
status: 204,
|
|
951
|
+
headers: {
|
|
952
|
+
'Access-Control-Allow-Origin': '*',
|
|
953
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
954
|
+
'Access-Control-Allow-Headers':
|
|
955
|
+
'Content-Type, Authorization, x-component-did, x-uploader-base-url, x-uploader-endpoint-url',
|
|
956
|
+
'Access-Control-Max-Age': '86400',
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Forward /media-kit/* requests to the Media Kit CF Worker
|
|
962
|
+
app.all('/media-kit/*', async (c) => {
|
|
963
|
+
const url = new URL(c.req.url);
|
|
964
|
+
const targetPath = url.pathname.replace(/^\/media-kit/, '') || '/';
|
|
965
|
+
const mediaKitUrl = c.env.MEDIA_KIT_URL || 'https://media-kit.yexiaofang.workers.dev';
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
const targetUrl = `${mediaKitUrl}${targetPath}${url.search}`;
|
|
969
|
+
|
|
970
|
+
// Forward relevant headers
|
|
971
|
+
const reqHeaders = new Headers();
|
|
972
|
+
for (const h of ['content-type', 'accept', 'authorization', 'x-component-did', 'x-user-did', 'x-csrf-token']) {
|
|
973
|
+
const v = c.req.header(h);
|
|
974
|
+
if (v) reqHeaders.set(h, v);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Use Service Binding if available (avoids 1042 same-zone error)
|
|
978
|
+
const mediaKit = c.env.MEDIA_KIT;
|
|
979
|
+
let resp: Response;
|
|
980
|
+
if (mediaKit?.fetch) {
|
|
981
|
+
resp = await mediaKit.fetch(
|
|
982
|
+
new Request(targetUrl, {
|
|
983
|
+
method: c.req.method,
|
|
984
|
+
headers: reqHeaders,
|
|
985
|
+
body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
|
|
986
|
+
})
|
|
987
|
+
);
|
|
988
|
+
} else {
|
|
989
|
+
// Fallback to direct fetch (may get 1042 on workers.dev)
|
|
990
|
+
resp = await fetch(targetUrl, {
|
|
991
|
+
method: c.req.method,
|
|
992
|
+
headers: reqHeaders,
|
|
993
|
+
body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const respHeaders = new Headers(resp.headers);
|
|
998
|
+
respHeaders.set('Access-Control-Allow-Origin', '*');
|
|
999
|
+
|
|
1000
|
+
// Rewrite relative URLs in JSON responses to absolute URLs with /media-kit prefix
|
|
1001
|
+
const ct = resp.headers.get('content-type') || '';
|
|
1002
|
+
if (ct.includes('application/json') && resp.status >= 200 && resp.status < 300) {
|
|
1003
|
+
try {
|
|
1004
|
+
const body = (await resp.json()) as Record<string, any>;
|
|
1005
|
+
const { origin } = url;
|
|
1006
|
+
// Rewrite presignedUrl and any other relative paths
|
|
1007
|
+
if (body.presignedUrl && body.presignedUrl.startsWith('/')) {
|
|
1008
|
+
body.presignedUrl = `${origin}/media-kit${body.presignedUrl}`;
|
|
1009
|
+
}
|
|
1010
|
+
if (body.url && typeof body.url === 'string' && body.url.startsWith('/')) {
|
|
1011
|
+
body.url = `${origin}/media-kit${body.url}`;
|
|
1012
|
+
}
|
|
1013
|
+
if (body.fileUrl && typeof body.fileUrl === 'string' && body.fileUrl.startsWith('/')) {
|
|
1014
|
+
body.fileUrl = `${origin}/media-kit${body.fileUrl}`;
|
|
1015
|
+
}
|
|
1016
|
+
return new Response(JSON.stringify(body), { status: resp.status, headers: respHeaders });
|
|
1017
|
+
} catch {
|
|
1018
|
+
// If JSON parse fails, return as-is
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return new Response(resp.body, { status: resp.status, headers: respHeaders });
|
|
1023
|
+
} catch (err: any) {
|
|
1024
|
+
console.error('[CF Worker] Media Kit proxy error:', err?.message);
|
|
1025
|
+
return c.json({ error: `Media Kit proxy error: ${err?.message || 'unknown'}` }, 502);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// SPA fallback — serve static assets or index.html for client-side routing
|
|
1030
|
+
app.all('*', async (c) => {
|
|
1031
|
+
const assets = c.env.ASSETS;
|
|
1032
|
+
if (!assets) {
|
|
1033
|
+
return c.text('Not found', 404);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Helper: rewrite HTML for mount prefix support.
|
|
1037
|
+
//
|
|
1038
|
+
// The inline bootstrap in public/index.html already pulls /__blocklet__.js?type=json
|
|
1039
|
+
// synchronously and overlays it into window.blocklet with localOnly protection.
|
|
1040
|
+
// For the root-mount case we do NOT inject an extra <script src="/__blocklet__.js">,
|
|
1041
|
+
// because that script runs a plain `window.blocklet = {...}` assignment and wipes
|
|
1042
|
+
// out navigation/componentMountPoints that the bootstrap just set.
|
|
1043
|
+
//
|
|
1044
|
+
// The mountPrefix case still needs to inject a script, because the inline bootstrap
|
|
1045
|
+
// hardcodes `pfx + '__blocklet__.js'` based on window.blocklet.prefix which — at
|
|
1046
|
+
// bootstrap time — defaults to '/' and doesn't know about the gateway-provided
|
|
1047
|
+
// mount prefix. The injected script provides a prefix-aware fallback.
|
|
1048
|
+
const rewriteHtml = async (htmlResponse: Response) => {
|
|
1049
|
+
if (!htmlResponse.headers.get('content-type')?.includes('text/html')) return htmlResponse;
|
|
1050
|
+
let html = await htmlResponse.text();
|
|
1051
|
+
const mountPrefix = c.req.header('X-Mount-Prefix');
|
|
1052
|
+
if (mountPrefix && mountPrefix !== '/') {
|
|
1053
|
+
const pfx = mountPrefix.endsWith('/') ? mountPrefix.slice(0, -1) : mountPrefix;
|
|
1054
|
+
// Rewrite absolute asset paths: src="/assets/..." → src="/payment/assets/..."
|
|
1055
|
+
html = html.replace(/((?:src|href)=["'])\/assets\//g, `$1${pfx}/assets/`);
|
|
1056
|
+
// NOTE: Do NOT inject <script src="/__blocklet__.js"> here.
|
|
1057
|
+
// The inline bootstrap already fetches __blocklet__.js?type=json via XHR and
|
|
1058
|
+
// merges the remote config while protecting localOnly fields (navigation,
|
|
1059
|
+
// componentMountPoints). The script tag does a plain `window.blocklet = {...}`
|
|
1060
|
+
// assignment which OVERWRITES those fields with empty values from AUTH_SERVICE,
|
|
1061
|
+
// breaking sidebar navigation and causing redirect-to-home on page refresh.
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Build a fresh headers object. Copying from htmlResponse.headers keeps the
|
|
1065
|
+
// original Cache-Control (public, max-age=...) from the asset binding, and
|
|
1066
|
+
// Cloudflare's edge cache then stores the HTML — leaving users stuck on
|
|
1067
|
+
// stale hashed bundle references after every deploy. HTML must never be
|
|
1068
|
+
// edge-cached; asset files (with hashed names) are immutable and can be.
|
|
1069
|
+
const headers = new Headers();
|
|
1070
|
+
const contentType = htmlResponse.headers.get('content-type');
|
|
1071
|
+
if (contentType) headers.set('Content-Type', contentType);
|
|
1072
|
+
// no-store beats CF edge cache (no-cache only means "revalidate",
|
|
1073
|
+
// which CF still treats as cacheable for GET/HEAD).
|
|
1074
|
+
headers.set('Cache-Control', 'no-store, must-revalidate, max-age=0');
|
|
1075
|
+
// Cloudflare-specific override — belt-and-braces against any edge cache policy.
|
|
1076
|
+
headers.set('CDN-Cache-Control', 'no-store');
|
|
1077
|
+
headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
|
|
1078
|
+
|
|
1079
|
+
return new Response(html, {
|
|
1080
|
+
status: htmlResponse.status,
|
|
1081
|
+
headers,
|
|
1082
|
+
});
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
try {
|
|
1086
|
+
const assetResponse = await assets.fetch(c.req.raw);
|
|
1087
|
+
if (assetResponse.status !== 404) {
|
|
1088
|
+
// HTML from assets (e.g. /index.html) also needs rewriting
|
|
1089
|
+
if (assetResponse.headers.get('content-type')?.includes('text/html')) {
|
|
1090
|
+
return rewriteHtml(assetResponse);
|
|
1091
|
+
}
|
|
1092
|
+
return assetResponse;
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Fall through to SPA fallback
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
const url = new URL(c.req.url);
|
|
1100
|
+
url.pathname = '/index.html';
|
|
1101
|
+
const htmlResponse = await assets.fetch(new Request(url.toString(), c.req.raw));
|
|
1102
|
+
return rewriteHtml(htmlResponse);
|
|
1103
|
+
} catch {
|
|
1104
|
+
return c.text('Not found', 404);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
cachedApp = app;
|
|
1109
|
+
cachedAppSK = env.APP_SK || '';
|
|
1110
|
+
return app;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// === Express-to-Hono Route Adapter ===
|
|
1114
|
+
|
|
1115
|
+
function normalizeRoutePath(prefix: string, routePath: string): string {
|
|
1116
|
+
let full = (prefix + routePath).replace(/\/+/g, '/');
|
|
1117
|
+
if (!full.startsWith('/')) full = `/${full}`;
|
|
1118
|
+
if (full.length > 1 && full.endsWith('/')) full = full.slice(0, -1);
|
|
1119
|
+
return full;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function createExpressReq(c: any, routeParams: Record<string, string>): any {
|
|
1123
|
+
const url = new URL(c.req.url);
|
|
1124
|
+
const query: Record<string, any> = {};
|
|
1125
|
+
url.searchParams.forEach((v, k) => {
|
|
1126
|
+
query[k] = v;
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const headers: Record<string, string> = {};
|
|
1130
|
+
c.req.raw.headers.forEach((v: string, k: string) => {
|
|
1131
|
+
headers[k.toLowerCase()] = v;
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
const req: any = {
|
|
1135
|
+
method: c.req.method,
|
|
1136
|
+
url: url.pathname + url.search,
|
|
1137
|
+
path: url.pathname,
|
|
1138
|
+
originalUrl: url.pathname + url.search,
|
|
1139
|
+
query,
|
|
1140
|
+
params: { ...routeParams },
|
|
1141
|
+
body: null,
|
|
1142
|
+
headers,
|
|
1143
|
+
user: null,
|
|
1144
|
+
livemode: true,
|
|
1145
|
+
baseCurrency: null,
|
|
1146
|
+
ip: headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '127.0.0.1',
|
|
1147
|
+
get(name: string) {
|
|
1148
|
+
return headers[name.toLowerCase()];
|
|
1149
|
+
},
|
|
1150
|
+
header(name: string) {
|
|
1151
|
+
return headers[name.toLowerCase()];
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
return req;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function createExpressRes(): any {
|
|
1159
|
+
const res: any = {
|
|
1160
|
+
_statusCode: 200,
|
|
1161
|
+
_headers: {} as Record<string, string>,
|
|
1162
|
+
_body: null as any,
|
|
1163
|
+
_sent: false,
|
|
1164
|
+
_redirectUrl: null as string | null,
|
|
1165
|
+
headersSent: false,
|
|
1166
|
+
|
|
1167
|
+
status(code: number) {
|
|
1168
|
+
res._statusCode = code;
|
|
1169
|
+
return res;
|
|
1170
|
+
},
|
|
1171
|
+
json(data: any) {
|
|
1172
|
+
if (res._sent) return res;
|
|
1173
|
+
res._sent = true;
|
|
1174
|
+
res.headersSent = true;
|
|
1175
|
+
res._body = data;
|
|
1176
|
+
res._headers['content-type'] = 'application/json';
|
|
1177
|
+
return res;
|
|
1178
|
+
},
|
|
1179
|
+
send(data: any) {
|
|
1180
|
+
if (res._sent) return res;
|
|
1181
|
+
res._sent = true;
|
|
1182
|
+
res.headersSent = true;
|
|
1183
|
+
res._body = data;
|
|
1184
|
+
return res;
|
|
1185
|
+
},
|
|
1186
|
+
redirect(urlOrStatus: any, url?: string) {
|
|
1187
|
+
res._sent = true;
|
|
1188
|
+
res.headersSent = true;
|
|
1189
|
+
if (typeof urlOrStatus === 'number') {
|
|
1190
|
+
res._statusCode = urlOrStatus;
|
|
1191
|
+
res._redirectUrl = url;
|
|
1192
|
+
} else {
|
|
1193
|
+
res._statusCode = 302;
|
|
1194
|
+
res._redirectUrl = urlOrStatus;
|
|
1195
|
+
}
|
|
1196
|
+
return res;
|
|
1197
|
+
},
|
|
1198
|
+
set(key: string, value: string) {
|
|
1199
|
+
res._headers[key.toLowerCase()] = value;
|
|
1200
|
+
return res;
|
|
1201
|
+
},
|
|
1202
|
+
setHeader(key: string, value: string) {
|
|
1203
|
+
res._headers[key.toLowerCase()] = value;
|
|
1204
|
+
return res;
|
|
1205
|
+
},
|
|
1206
|
+
cookie(_name: string, _value: string, _options?: any) {
|
|
1207
|
+
return res;
|
|
1208
|
+
},
|
|
1209
|
+
end() {
|
|
1210
|
+
if (!res._sent) {
|
|
1211
|
+
res._sent = true;
|
|
1212
|
+
res.headersSent = true;
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
type(t: string) {
|
|
1216
|
+
res._headers['content-type'] = t;
|
|
1217
|
+
return res;
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
Object.defineProperty(res, 'statusCode', {
|
|
1222
|
+
get() {
|
|
1223
|
+
return res._statusCode;
|
|
1224
|
+
},
|
|
1225
|
+
set(v: number) {
|
|
1226
|
+
res._statusCode = v;
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
return res;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function expressResToResponse(res: any): Response {
|
|
1234
|
+
if (res._redirectUrl) {
|
|
1235
|
+
return Response.redirect(res._redirectUrl, res._statusCode || 302);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const headers = new Headers(res._headers);
|
|
1239
|
+
|
|
1240
|
+
if (res._body === null || res._body === undefined) {
|
|
1241
|
+
return new Response(null, { status: res._statusCode, headers });
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (typeof res._body === 'string') {
|
|
1245
|
+
return new Response(res._body, { status: res._statusCode, headers });
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (res._body instanceof ArrayBuffer || res._body instanceof Uint8Array) {
|
|
1249
|
+
return new Response(res._body, { status: res._statusCode, headers });
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (!headers.has('content-type')) {
|
|
1253
|
+
headers.set('content-type', 'application/json');
|
|
1254
|
+
}
|
|
1255
|
+
let jsonStr = JSON.stringify(res._body);
|
|
1256
|
+
// Rewrite legacy blocklet server URLs to CF Workers domain
|
|
1257
|
+
// Old format: https://old-domain/payment/methods/x.png -> https://cf-domain/methods/x.png
|
|
1258
|
+
const cfAppUrl = ((globalThis as any).__CF_ENV__?.APP_URL || '').replace(/\/$/, '');
|
|
1259
|
+
if (cfAppUrl) {
|
|
1260
|
+
jsonStr = jsonStr
|
|
1261
|
+
.split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io/payment/')
|
|
1262
|
+
.join(`${cfAppUrl}/`);
|
|
1263
|
+
jsonStr = jsonStr.split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io').join(cfAppUrl);
|
|
1264
|
+
}
|
|
1265
|
+
return new Response(jsonStr, { status: res._statusCode, headers });
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function runExpressHandlers(handlers: Function[], req: any, res: any): Promise<void> {
|
|
1269
|
+
let idx = 0;
|
|
1270
|
+
|
|
1271
|
+
async function runNext(err?: any): Promise<void> {
|
|
1272
|
+
if (err) {
|
|
1273
|
+
console.error('[CF Worker] Express middleware error:', err?.message || err);
|
|
1274
|
+
if (!res._sent) {
|
|
1275
|
+
res.status(500).json({ error: err?.message || 'Internal Server Error' });
|
|
1276
|
+
}
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (idx >= handlers.length || res._sent) return;
|
|
1280
|
+
|
|
1281
|
+
const handler = handlers[idx++];
|
|
1282
|
+
if (!handler) return runNext();
|
|
1283
|
+
|
|
1284
|
+
if (handler.length === 4) {
|
|
1285
|
+
return runNext();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return new Promise<void>((resolve) => {
|
|
1289
|
+
let nextCalled = false;
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
const result = handler(req, res, (nextErr?: any) => {
|
|
1293
|
+
nextCalled = true;
|
|
1294
|
+
runNext(nextErr)
|
|
1295
|
+
.then(resolve)
|
|
1296
|
+
.catch((e: any) => {
|
|
1297
|
+
if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
|
|
1298
|
+
resolve();
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
if (result && typeof result.then === 'function') {
|
|
1303
|
+
result
|
|
1304
|
+
.then(() => {
|
|
1305
|
+
if (!nextCalled) {
|
|
1306
|
+
resolve();
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
.catch((e: any) => {
|
|
1310
|
+
console.error('[CF Worker] Async handler error:', e?.message || e);
|
|
1311
|
+
if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
|
|
1312
|
+
resolve();
|
|
1313
|
+
});
|
|
1314
|
+
} else if (!nextCalled) {
|
|
1315
|
+
resolve();
|
|
1316
|
+
}
|
|
1317
|
+
} catch (e: any) {
|
|
1318
|
+
console.error('[CF Worker] Sync handler error:', e?.message || e);
|
|
1319
|
+
if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
|
|
1320
|
+
resolve();
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
await runNext();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function mountExpressRoutes(honoApp: Hono<HonoEnv>, prefix: string, expressRouter: any) {
|
|
1329
|
+
const routes: RouteEntry[] = expressRouter._routes || [];
|
|
1330
|
+
|
|
1331
|
+
console.log(`[CF Worker] Mounting ${routes.length} Express routes under ${prefix}`);
|
|
1332
|
+
|
|
1333
|
+
for (const route of routes) {
|
|
1334
|
+
const fullPath = normalizeRoutePath(prefix, route.path);
|
|
1335
|
+
const method = route.method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
1336
|
+
|
|
1337
|
+
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
|
|
1338
|
+
console.warn(`[CF Worker] Skipping unsupported method: ${route.method} ${fullPath}`);
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
honoApp[method](fullPath, async (c) => {
|
|
1343
|
+
const req = createExpressReq(c, c.req.param());
|
|
1344
|
+
const res = createExpressRes();
|
|
1345
|
+
|
|
1346
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method)) {
|
|
1347
|
+
try {
|
|
1348
|
+
const contentType = c.req.header('content-type') || '';
|
|
1349
|
+
const isStripeWebhook = fullPath.includes('/integrations/stripe/webhook');
|
|
1350
|
+
|
|
1351
|
+
if (isStripeWebhook) {
|
|
1352
|
+
const rawBody = await c.req.arrayBuffer();
|
|
1353
|
+
req.body = Buffer.from(rawBody);
|
|
1354
|
+
req.rawBody = req.body;
|
|
1355
|
+
} else if (contentType.includes('application/json')) {
|
|
1356
|
+
req.body = await c.req.json();
|
|
1357
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
1358
|
+
const text = await c.req.text();
|
|
1359
|
+
req.body = Object.fromEntries(new URLSearchParams(text));
|
|
1360
|
+
} else if (contentType.includes('text/')) {
|
|
1361
|
+
req.body = await c.req.text();
|
|
1362
|
+
} else {
|
|
1363
|
+
try {
|
|
1364
|
+
req.body = await c.req.json();
|
|
1365
|
+
} catch {
|
|
1366
|
+
try {
|
|
1367
|
+
req.body = await c.req.text();
|
|
1368
|
+
} catch {
|
|
1369
|
+
req.body = null;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
} catch {
|
|
1374
|
+
req.body = {};
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Debug logging for webhook
|
|
1379
|
+
if (fullPath.includes('stripe/webhook')) {
|
|
1380
|
+
console.log('[CF Worker] Stripe webhook request received:', {
|
|
1381
|
+
method: c.req.method,
|
|
1382
|
+
path: fullPath,
|
|
1383
|
+
hasSignature: !!req.headers['stripe-signature'],
|
|
1384
|
+
bodyType: typeof req.body,
|
|
1385
|
+
bodyLength: req.body?.length || 0,
|
|
1386
|
+
isBuffer: Buffer.isBuffer(req.body),
|
|
1387
|
+
handlersCount: route.handlers.length,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Inject caller identity resolved by AUTH_SERVICE RPC (or mock fallback)
|
|
1392
|
+
const caller: CallerIdentityDTO | null = c.get('caller');
|
|
1393
|
+
if (caller) {
|
|
1394
|
+
req.user = {
|
|
1395
|
+
did: caller.did,
|
|
1396
|
+
role: caller.role || 'guest',
|
|
1397
|
+
provider: caller.authMethod === 'access-key' ? 'access-key' : 'wallet',
|
|
1398
|
+
fullName: caller.displayName || '',
|
|
1399
|
+
walletOS: '',
|
|
1400
|
+
via: 'dashboard',
|
|
1401
|
+
};
|
|
1402
|
+
req.headers['x-user-did'] = caller.did;
|
|
1403
|
+
req.headers['x-user-role'] = `blocklet-${caller.role || 'guest'}`;
|
|
1404
|
+
req.headers['x-user-provider'] = caller.authMethod || 'wallet';
|
|
1405
|
+
req.headers['x-user-fullname'] = encodeURIComponent(caller.displayName || '');
|
|
1406
|
+
req.headers['x-user-wallet-os'] = '';
|
|
1407
|
+
} else {
|
|
1408
|
+
req.user = { did: '', role: 'guest', provider: '', fullName: '', walletOS: '', via: '' };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
try {
|
|
1412
|
+
await runExpressHandlers(route.handlers, req, res);
|
|
1413
|
+
} catch (e: any) {
|
|
1414
|
+
console.error(
|
|
1415
|
+
`[CF Worker] Unhandled error in ${route.method} ${fullPath}:`,
|
|
1416
|
+
e?.message || e,
|
|
1417
|
+
'\n',
|
|
1418
|
+
e?.stack?.split('\n').slice(0, 8).join('\n')
|
|
1419
|
+
);
|
|
1420
|
+
if (!res._sent) {
|
|
1421
|
+
res.status(500).json({ error: e?.message || 'Internal Server Error' });
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Debug logging for webhook response
|
|
1426
|
+
if (fullPath.includes('stripe/webhook')) {
|
|
1427
|
+
console.log('[CF Worker] Stripe webhook handler result:', {
|
|
1428
|
+
sent: res._sent,
|
|
1429
|
+
statusCode: res._statusCode,
|
|
1430
|
+
body:
|
|
1431
|
+
typeof res._body === 'string' ? res._body.substring(0, 200) : JSON.stringify(res._body)?.substring(0, 200),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Ensure all async push() jobs complete before returning
|
|
1436
|
+
await flushPendingJobs();
|
|
1437
|
+
|
|
1438
|
+
if (!res._sent) {
|
|
1439
|
+
console.warn(`[CF Worker] No response sent for ${route.method} ${fullPath}`);
|
|
1440
|
+
return c.json({ error: 'No response from handler' }, 500);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return expressResToResponse(res);
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// === Shared env setup for scheduled/queue handlers ===
|
|
1449
|
+
function setupEnv(env: Env) {
|
|
1450
|
+
if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
|
|
1451
|
+
(globalThis as any).__flushDeferredTimers();
|
|
1452
|
+
}
|
|
1453
|
+
(globalThis as any).__CF_ENV__ = env;
|
|
1454
|
+
// Queue consumer and cron: NOT HTTP context — createEvent must block (listeners complete before ack/return)
|
|
1455
|
+
(globalThis as any).__cfHttpContext__ = false;
|
|
1456
|
+
setDB(env.DB.withSession('first-primary'));
|
|
1457
|
+
if (env.JOB_QUEUE) setCFQueue(env.JOB_QUEUE);
|
|
1458
|
+
ensureModelsInit();
|
|
1459
|
+
|
|
1460
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
1461
|
+
if (env.APP_URL) {
|
|
1462
|
+
process.env.APP_URL = env.APP_URL;
|
|
1463
|
+
process.env.BLOCKLET_APP_URL = env.APP_URL;
|
|
1464
|
+
}
|
|
1465
|
+
if (env.APP_PID) {
|
|
1466
|
+
process.env.BLOCKLET_APP_PID = env.APP_PID;
|
|
1467
|
+
process.env.BLOCKLET_APP_ID = env.APP_PID;
|
|
1468
|
+
}
|
|
1469
|
+
if (env.APP_NAME) process.env.BLOCKLET_APP_NAME = env.APP_NAME;
|
|
1470
|
+
process.env.BLOCKLET_MODE = 'production';
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Security init is handled in the per-request middleware (first request only)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// === Export ===
|
|
1477
|
+
export default {
|
|
1478
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
1479
|
+
setWaitUntil((p) => ctx.waitUntil(p));
|
|
1480
|
+
// Expose waitUntil globally for createEvent to use in HTTP context
|
|
1481
|
+
(globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);
|
|
1482
|
+
|
|
1483
|
+
const app = buildApp(env);
|
|
1484
|
+
return app.fetch(request, env, ctx);
|
|
1485
|
+
},
|
|
1486
|
+
|
|
1487
|
+
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
|
|
1488
|
+
setupEnv(env);
|
|
1489
|
+
ensureCronsInit();
|
|
1490
|
+
setWaitUntil((p) => ctx.waitUntil(p));
|
|
1491
|
+
|
|
1492
|
+
console.log('Scheduled event:', event.cron, 'scheduledTime:', event.scheduledTime);
|
|
1493
|
+
|
|
1494
|
+
// Refund recovery (startRefundQueue) is registered as a proper cron job in
|
|
1495
|
+
// api/src/crons/index.ts (refund.recovery, */5 min), so it is invoked via
|
|
1496
|
+
// cronInstance.runAll() below rather than on every scheduled tick.
|
|
1497
|
+
//
|
|
1498
|
+
// Pass the event's scheduledTime (the INTENDED minute) instead of letting
|
|
1499
|
+
// runAll() use new Date() internally. If CF delivers this event late and
|
|
1500
|
+
// execution crosses a minute boundary, matching on wall-clock would miss
|
|
1501
|
+
// exact-minute crons like "0 1 * * * *".
|
|
1502
|
+
await cronInstance.runAll(new Date(event.scheduledTime));
|
|
1503
|
+
await runAllScheduledJobs();
|
|
1504
|
+
await flushPendingJobs();
|
|
1505
|
+
},
|
|
1506
|
+
|
|
1507
|
+
// CF Queue consumer — processes jobs sent by push() in the queue shim
|
|
1508
|
+
async queue(
|
|
1509
|
+
batch: MessageBatch<{ queueName: string; jobId: string; job: any; persist?: boolean }>,
|
|
1510
|
+
env: Env,
|
|
1511
|
+
ctx: ExecutionContext,
|
|
1512
|
+
) {
|
|
1513
|
+
setupEnv(env);
|
|
1514
|
+
setWaitUntil((p) => ctx.waitUntil(p));
|
|
1515
|
+
|
|
1516
|
+
console.log(`[queue:consumer] Received batch of ${batch.messages.length} messages`);
|
|
1517
|
+
|
|
1518
|
+
for (const msg of batch.messages) {
|
|
1519
|
+
const { queueName, jobId, job, persist } = msg.body;
|
|
1520
|
+
|
|
1521
|
+
const handler = getHandler(queueName);
|
|
1522
|
+
|
|
1523
|
+
if (!handler) {
|
|
1524
|
+
console.error(`[queue:consumer] No handler registered for queue "${queueName}", acking message`);
|
|
1525
|
+
msg.ack();
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// persist defaults to true for backward compatibility and for direct
|
|
1530
|
+
// push() immediate jobs (where addJob wrote the row, so we must delete
|
|
1531
|
+
// it after onJob succeeds).
|
|
1532
|
+
//
|
|
1533
|
+
// Scheduled dispatches from runAllScheduledJobs set persist=false —
|
|
1534
|
+
// the dispatcher already deleted the D1 row before sending, and
|
|
1535
|
+
// onJob may have re-pushed a fresh row with the same id; deleting
|
|
1536
|
+
// again would wipe out that new row.
|
|
1537
|
+
const shouldPersist = persist !== false;
|
|
1538
|
+
|
|
1539
|
+
try {
|
|
1540
|
+
console.log(`[queue:consumer] Processing ${queueName}:${jobId} (persist=${shouldPersist})`);
|
|
1541
|
+
await handler.executeJob(jobId, job, shouldPersist);
|
|
1542
|
+
console.log(`[queue:consumer] Completed ${queueName}:${jobId}`);
|
|
1543
|
+
msg.ack();
|
|
1544
|
+
} catch (err: any) {
|
|
1545
|
+
console.error(`[queue:consumer] Failed ${queueName}:${jobId}:`, err?.message || err);
|
|
1546
|
+
// Don't retry via CF Queue — job is in D1, cron will re-dispatch
|
|
1547
|
+
msg.ack();
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
await flushPendingJobs();
|
|
1552
|
+
},
|
|
1553
|
+
};
|