@homie-rocks/studio 0.54.0 → 0.55.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/CHANGELOG.md +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +35 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const officeShopPage = async (...args) => (await import('./shop-page.mjs')).officeShopPage(...args);
|
|
1
2
|
import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
|
|
2
3
|
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
3
4
|
import { localSession } from './local-test.mjs';
|
|
@@ -47,7 +48,7 @@ import {
|
|
|
47
48
|
setBand, shopDataOf, spentThisMonth,
|
|
48
49
|
} from './shop-store.mjs';
|
|
49
50
|
import { apiBase, STRIPE_API, redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
50
|
-
|
|
51
|
+
|
|
51
52
|
import { notFoundPage } from './site.mjs';
|
|
52
53
|
|
|
53
54
|
export { forgetPlayerShop, officeShopPage, shopDataOf };
|
|
@@ -217,7 +218,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
217
218
|
const requestAt = Date.now();
|
|
218
219
|
const path = url.pathname;
|
|
219
220
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
220
|
-
if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
221
|
+
if (path === '/_homie/shop.js') return new Response((await import('./shop-page.mjs')).SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
221
222
|
const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
|
|
222
223
|
if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
|
|
223
224
|
if (!shopPath) return null;
|
|
@@ -230,7 +231,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
230
231
|
if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
|
|
231
232
|
if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
|
|
232
233
|
// No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
|
|
233
|
-
if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
|
|
234
|
+
if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return (await import('./shop-page.mjs')).shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
|
|
234
235
|
return shop ? notFoundPage('This studio sells nothing.', cat) : null;
|
|
235
236
|
}
|
|
236
237
|
const ready = await readiness(env, shop);
|
|
@@ -271,9 +272,9 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
271
272
|
if (path === '/api/shop/parent' && request.method === 'POST') return await parentLinkRoute(request, env, url, cat, shop, ready);
|
|
272
273
|
if (path === '/api/shop/used' && request.method === 'POST') return await usedRoute(request, env, shop);
|
|
273
274
|
if (path === '/api/shop/refund' && request.method === 'POST') return await selfRefundRoute(request, env, shop, ready);
|
|
274
|
-
if (path === '/shop/' && read) return shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
|
|
275
|
-
if (path === '/shop/thanks' && read) return thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
|
|
276
|
-
if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return refundsPage(cat, shop);
|
|
275
|
+
if (path === '/shop/' && read) return (await import('./shop-page.mjs')).shopPage(cat, shop, { origin: url.origin, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null, item: url.searchParams.get('item'), open: ready.ready, mode: ready.mode });
|
|
276
|
+
if (path === '/shop/thanks' && read) return (await import('./shop-page.mjs')).thanksPage(cat, shop, { session: /^cs_[A-Za-z0-9_]{1,200}$/.test(url.searchParams.get('session_id') ?? '') ? url.searchParams.get('session_id') : null, game: GAME_ID.test(url.searchParams.get('game') ?? '') ? url.searchParams.get('game') : null });
|
|
277
|
+
if ((path === '/shop/refunds/' || path === '/shop/refunds') && read) return (await import('./shop-page.mjs')).refundsPage(cat, shop);
|
|
277
278
|
const parent = /^\/shop\/parent\/([A-Za-z0-9_-]{43})$/.exec(path);
|
|
278
279
|
if (parent) return await parentRoute(request, env, url, cat, shop, ready, parent[1], ctx);
|
|
279
280
|
} catch (error) {
|
|
@@ -738,35 +739,35 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
|
|
|
738
739
|
const cart = row ? row.cart ? JSON.parse(row.cart) : [{ item: row.item, quantity: 1 }] : [];
|
|
739
740
|
const lines = cart.map((entry) => ({ item: shop.items.find((i) => i.id === entry.item), quantity: entry.quantity }));
|
|
740
741
|
const item = lines[0]?.item;
|
|
741
|
-
if (lines.some((line) => !line.item)) return parentPage(cat, shop, { gone: true });
|
|
742
|
-
if (!row || !item || Number(row.expires_at) < Date.now()) return parentPage(cat, shop, { gone: true });
|
|
742
|
+
if (lines.some((line) => !line.item)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
743
|
+
if (!row || !item || Number(row.expires_at) < Date.now()) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
743
744
|
const kid = await players.get(env, row.player);
|
|
744
|
-
if (!kid) return parentPage(cat, shop, { gone: true });
|
|
745
|
+
if (!kid) return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
745
746
|
const done = row.order_id ? await orderById(env, row.order_id) : null;
|
|
746
|
-
if (done?.status === 'paid') return parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
|
|
747
|
-
if (request.method === 'GET' || request.method === 'HEAD') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
|
|
747
|
+
if (done?.status === 'paid') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, paid: true });
|
|
748
|
+
if (request.method === 'GET' || request.method === 'HEAD') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready });
|
|
748
749
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
|
|
749
750
|
let form = null;
|
|
750
751
|
try { form = await request.formData(); } catch { form = null; }
|
|
751
|
-
if (String(form?.get('grownup') ?? '') !== 'yes') return parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
|
|
752
|
-
if (!ready.ready) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
|
|
753
|
-
if (purchaseLimited(request, shop, row.player, 'parentpay')) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
|
|
752
|
+
if (String(form?.get('grownup') ?? '') !== 'yes') return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: ready.ready, said: 'Tick the box to say you are their parent or guardian and an adult.' });
|
|
753
|
+
if (!ready.ready) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: false });
|
|
754
|
+
if (purchaseLimited(request, shop, row.player, 'parentpay')) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: 'One moment: too many tries.' });
|
|
754
755
|
const band = await bandOfPlayer(env, row.player);
|
|
755
756
|
const owns = await ownsOf(env, row.player);
|
|
756
757
|
let amount = 0;
|
|
757
758
|
for (const [index, line] of lines.entries()) {
|
|
758
759
|
const it = line.item;
|
|
759
760
|
const owned = it.gives.length > 0 && it.gives.every((key) => owns.some((o) => o.key === key && o.item === it.id));
|
|
760
|
-
if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return parentPage(cat, shop, { gone: true });
|
|
761
|
+
if (wayFor(it, { player: kid, band, owned, spent: 0, cap: null, policy: shop.policy }) !== 'ask-a-parent') return (await import('./shop-page.mjs')).parentPage(cat, shop, { gone: true });
|
|
761
762
|
line.amount = it.price === 'choose' ? parseAmount(form?.get(lines.length === 1 ? 'amount' : `amount_${index}`), currencyScale(shop.currency)) : it.price;
|
|
762
763
|
const invalid = amountError(line.amount, shop.currency);
|
|
763
|
-
if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
|
|
764
|
+
if (invalid || it.price === 'choose' && (line.amount < it.min || it.max !== null && line.amount > it.max)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: invalid ?? 'This amount is outside the studio range.' });
|
|
764
765
|
const total = BigInt(amount) + BigInt(line.amount) * BigInt(line.quantity);
|
|
765
|
-
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
|
|
766
|
+
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
|
|
766
767
|
amount = Number(total);
|
|
767
768
|
}
|
|
768
769
|
const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
|
|
769
|
-
if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
|
|
770
|
+
if (r.error) return (await import('./shop-page.mjs')).parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
|
|
770
771
|
await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
|
|
771
772
|
if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
|
|
772
773
|
return Response.redirect(r.url, 303);
|
|
@@ -836,7 +837,13 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
836
837
|
if (payload === null) return fail(413, 'too-large', 'The studio webhook byte limit was reached.');
|
|
837
838
|
const v = await verifyWebhook(payload, request.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET);
|
|
838
839
|
if (!v.ok) return fail(v.why === 'no-secret' ? 503 : 400, v.why, 'not a webhook from this studio\'s Stripe');
|
|
839
|
-
|
|
840
|
+
if(env.BACKGROUND)return (await import('./stripe-intake.mjs')).acceptStripeEvent(env,v.event,new URL(request.url).origin);
|
|
841
|
+
return reconcileShopEvent(request,env,shop,v.event);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Internal verified-event seam shared by direct legacy intake and the queue consumer. */
|
|
845
|
+
export async function reconcileShopEvent(request,env,shop,ev) {
|
|
846
|
+
shop??=checkShop({}).shop;
|
|
840
847
|
const obj = ev.data?.object ?? {};
|
|
841
848
|
const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
|
|
842
849
|
if (selling.enabled) {
|
|
@@ -955,7 +962,6 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
955
962
|
if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
|
|
956
963
|
if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
|
|
957
964
|
await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run();
|
|
958
|
-
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
959
965
|
return json({ ok: true, did });
|
|
960
966
|
} catch (error) {
|
|
961
967
|
if(/(?:booking|resource) capacity unavailable/.test(error.message)&&obj.payment_link&&await reviewLinkPayment(env,obj,ev.livemode?'live':'test','The paid group exceeds remaining capacity. Staff will arrange a replacement or refund.'))return json({ok:true,did:'review'});
|
|
@@ -1443,6 +1449,7 @@ export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor =
|
|
|
1443
1449
|
},
|
|
1444
1450
|
keyless: !hasStripeKey(env),
|
|
1445
1451
|
hook: `${origin}/api/shop/hook`,
|
|
1452
|
+
...(env.BACKGROUND ? {delivery:await (await import('./stripe-intake.mjs')).stripeIntakeStatus(env)} : {}),
|
|
1446
1453
|
totals: null, orders: [],
|
|
1447
1454
|
};
|
|
1448
1455
|
if (!(await migrated(env, { complete: false }))) return out;
|
package/worker/stats.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {writePageMetric,analyticsStatus} from './analytics.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
|
|
3
4
|
*
|
|
@@ -5,7 +6,7 @@
|
|
|
5
6
|
* party, no cookie is set on a visitor, and no visitor is identified: a count is a number on a day, never a row
|
|
6
7
|
* about a person. Referrers are kept as a host name only (never a path, a query or an address).
|
|
7
8
|
*
|
|
8
|
-
* WHAT IS COUNTED
|
|
9
|
+
* WHAT IS COUNTED (page metrics go to Analytics Engine; daily rollups preserve D1 history):
|
|
9
10
|
* visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
|
|
10
11
|
* play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
|
|
11
12
|
* screen a big screen opened (/<game>/tv)
|
|
@@ -36,9 +37,8 @@
|
|
|
36
37
|
* (HttpOnly, SameSite=Lax; from 0.13.0 at / so the owner is recognised in their own games, never readable by a
|
|
37
38
|
* page or a game). There is no other way in.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* humans counters). Past the day's writes, counting stops until 00:00 UTC and the site keeps working.
|
|
40
|
+
* Page totals refresh hourly and can be sampled at high traffic. Older wrappers without the analytics
|
|
41
|
+
* binding retain D1 counting. Game, payment and budget facts remain in durable storage.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
export const STATS_MIGRATION_FILE = '0002_studio_stats.sql';
|
|
@@ -97,9 +97,10 @@ export function counter(env, { metric, subject = '', source = '', n = 1, day = t
|
|
|
97
97
|
|
|
98
98
|
/** Run a counter write after the response (ctx.waitUntil), or before it when the caller gave no ctx. Never throws. */
|
|
99
99
|
export async function count(env, ctx, row) {
|
|
100
|
+
if (writePageMetric(env, row)) return;
|
|
100
101
|
const stmt = counter(env, row);
|
|
101
102
|
if (!stmt) return;
|
|
102
|
-
const p = stmt.run().catch(
|
|
103
|
+
const p = stmt.run().catch(error => console.warn("Studio counter dropped:",error.message)); // before migration 0002, or past the free plan's daily writes: not counted
|
|
103
104
|
if (ctx?.waitUntil) ctx.waitUntil(p); else await p;
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -339,7 +340,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
|
|
|
339
340
|
// The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
|
|
340
341
|
pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
|
|
341
342
|
games: perGame, songs: media(songs, 'song', 'music'), videos: media(videos, 'video', 'videos'), referrers, days,
|
|
342
|
-
note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.',
|
|
343
|
+
note: 'Counts, never people: a visit is a page a browser opened, a play is a Play press, a round is a finished round. Prefetches, crawlers and house QA are not counted. Times are UTC days.' + (env.STUDIO_ANALYTICS && env.STUDIO_ANALYTICS_DATASET ? ' Page visits, plays, screens and watches show the last hourly rollup; recent activity appears after ingestion and the next rollup. These counters may be sampled at high traffic.' + await analyticsStatus(env).then(s=>s.delayed ? ' Counts are delayed: '+(s.error??'awaiting a successful rollup')+'.' : '') : ''),
|
|
343
344
|
};
|
|
344
345
|
}
|
|
345
346
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Streams use independent namespaces so subscribers never warm matchmaking state. */
|
|
2
|
+
export class AppRecordHub {
|
|
3
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
4
|
+
async fetch(request) {
|
|
5
|
+
const {AppRecordStreams}=await import('./app-streams.mjs');
|
|
6
|
+
this.streams??=new AppRecordStreams(this.env,this.ctx);
|
|
7
|
+
const input=await request.json();
|
|
8
|
+
return input.op==='open'?this.streams.open(input):this.streams.wake(input.meta);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class EventHub {
|
|
12
|
+
constructor(ctx, env) { this.ctx=ctx; this.env=env; }
|
|
13
|
+
async fetch(request) {
|
|
14
|
+
const {EventStreams}=await import('./event-resources.mjs');
|
|
15
|
+
this.streams??=new EventStreams(this.env,this.ctx);
|
|
16
|
+
return this.streams.fetch(request);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {RECONCILE_GUARD,guardedDatabase} from './reconcile-guard.mjs';
|
|
2
|
+
/** Called only after raw-body signature verification. D1 owns the work; queues
|
|
3
|
+
* are wakeups and may be unavailable or exhaust their daily allowance. */
|
|
4
|
+
export async function acceptStripeEvent(env,event,origin) {
|
|
5
|
+
await env.DB.prepare('INSERT INTO stripe_intake(id,payload,origin,received) VALUES(?1,?2,?3,?4) ON CONFLICT(id) DO NOTHING')
|
|
6
|
+
.bind(event.id,JSON.stringify(event),origin,Date.now()).run();
|
|
7
|
+
try { await env.BACKGROUND.send({v:1,type:'stripe.event',id:event.id}); }
|
|
8
|
+
catch(error) { console.warn('Stripe delivery deferred to the scheduled D1 sweep:',error.message); }
|
|
9
|
+
return Response.json({ok:true,queued:true});
|
|
10
|
+
}
|
|
11
|
+
async function reconcile(env,row,event,cat) {
|
|
12
|
+
const {reconcileShopEvent,shopOf}=await import('./shop.mjs');
|
|
13
|
+
return reconcileShopEvent(new Request(new URL('/api/shop/hook',row.origin)),env,shopOf(cat),event);
|
|
14
|
+
}
|
|
15
|
+
export async function reconcileStripeEvent(env,id,cat,{leaseMs=300000,reconcile:deliver=reconcile}={}) {
|
|
16
|
+
const now=Date.now();let lease=now+leaseMs;
|
|
17
|
+
const row=await env.DB.prepare('UPDATE stripe_intake SET lease=?2 WHERE id=?1 AND done IS NULL AND lease<?3 RETURNING *').bind(id,lease,now).first();
|
|
18
|
+
if(!row) {
|
|
19
|
+
const pending=await env.DB.prepare('SELECT done,lease FROM stripe_intake WHERE id=?1').bind(id).first();
|
|
20
|
+
if(pending?.done)return;
|
|
21
|
+
const error=new Error('Stripe event is missing or already being reconciled');
|
|
22
|
+
if(pending?.lease>=now)error.retryAfterSeconds=Math.max(1,Math.min(301,Math.ceil((pending.lease-now)/1000)+1));
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
let renewal=Promise.resolve(),lost=null;
|
|
26
|
+
const timer=setInterval(()=>{
|
|
27
|
+
renewal=renewal.then(async()=>{
|
|
28
|
+
if(lost)return;
|
|
29
|
+
const next=Date.now()+leaseMs;
|
|
30
|
+
const renewed=await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2 AND done IS NULL RETURNING id').bind(id,lease,next).first();
|
|
31
|
+
if(!renewed)throw new Error('Stripe reconciliation lease was lost');
|
|
32
|
+
lease=next;
|
|
33
|
+
}).catch(error=>{lost=error;});
|
|
34
|
+
},Math.max(1,Math.floor(leaseMs/3)));
|
|
35
|
+
const check=async()=>{
|
|
36
|
+
await renewal;
|
|
37
|
+
if(lost)throw lost;
|
|
38
|
+
if(Date.now()>=lease)throw new Error('Stripe reconciliation lease expired');
|
|
39
|
+
};
|
|
40
|
+
const guarded={...env,DB:guardedDatabase(env.DB,check),[RECONCILE_GUARD]:check};
|
|
41
|
+
try {
|
|
42
|
+
await check();
|
|
43
|
+
const event=JSON.parse(row.payload);
|
|
44
|
+
if(event.id!==id)throw new Error('Stored Stripe event id differs');
|
|
45
|
+
const response=await deliver(guarded,row,event,cat);
|
|
46
|
+
if(!response.ok)throw new Error('Stripe reconciliation needs retry ('+response.status+')');
|
|
47
|
+
clearInterval(timer);await renewal;
|
|
48
|
+
if(lost)throw lost;
|
|
49
|
+
const done=await env.DB.prepare('UPDATE stripe_intake SET done=?2,lease=0 WHERE id=?1 AND lease=?3 AND lease>?2 RETURNING id').bind(id,Date.now(),lease).first();
|
|
50
|
+
if(!done)throw new Error('Stripe reconciliation lease lost before completion');
|
|
51
|
+
} catch(error) {
|
|
52
|
+
clearInterval(timer);await renewal;
|
|
53
|
+
await env.DB.prepare('UPDATE stripe_intake SET lease=?3 WHERE id=?1 AND lease=?2').bind(id,lease,Date.now()+60000).run();
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {clearInterval(timer);}
|
|
56
|
+
}
|
|
57
|
+
export async function stripeIntakeStatus(env) {
|
|
58
|
+
if(!env.DB)return {pending:0,stuck:0};
|
|
59
|
+
const row=await env.DB.prepare('SELECT COUNT(*) AS pending,COALESCE(SUM(received<?1),0) AS stuck,MIN(received) AS oldest FROM stripe_intake WHERE done IS NULL').bind(Date.now()-3600000).first();
|
|
60
|
+
return {...row,message:row?.stuck?'Stripe deliveries are delayed. Check Worker logs and Stripe key permissions; scheduled recovery retries unfinished events.':null};
|
|
61
|
+
}
|
|
62
|
+
export async function sweepStripeEvents(env,cat) {
|
|
63
|
+
if(!env.DB)return;
|
|
64
|
+
const rows=(await env.DB.prepare('SELECT id FROM stripe_intake WHERE done IS NULL AND lease<?1 ORDER BY lease,received,id LIMIT 100').bind(Date.now()).all()).results??[];
|
|
65
|
+
for(const row of rows) {
|
|
66
|
+
try {await reconcileStripeEvent(env,row.id,cat);}
|
|
67
|
+
catch(error) {console.error('Stripe intake recovery failed',row.id,error.message);}
|
|
68
|
+
}
|
|
69
|
+
const status=await stripeIntakeStatus(env);
|
|
70
|
+
if(status.stuck)console.error('Stripe intake needs attention',status);
|
|
71
|
+
return status;
|
|
72
|
+
}
|
|
73
|
+
export async function pruneStripeEvents(env) {
|
|
74
|
+
const cutoff=Date.now()-60*86400000;
|
|
75
|
+
await env.DB.batch([
|
|
76
|
+
...(env.BACKGROUND?[env.DB.prepare('DELETE FROM stripe_intake WHERE id IN (SELECT id FROM stripe_intake WHERE done<?1 ORDER BY done LIMIT 1000)').bind(cutoff)]:[]),
|
|
77
|
+
env.DB.prepare('DELETE FROM shop_events WHERE id IN (SELECT id FROM shop_events WHERE at<?1 ORDER BY at LIMIT 1000)').bind(cutoff),
|
|
78
|
+
]);
|
|
79
|
+
}
|
package/worker/stripe.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {RECONCILE_GUARD} from './reconcile-guard.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE STUDIO'S OWN STRIPE, from its Worker (@homie-rocks/studio 0.24.0). No SDK: a few form-encoded calls with the
|
|
3
4
|
* studio's key (a Worker secret), and Stripe's documented webhook signature check with WebCrypto.
|
|
@@ -95,6 +96,7 @@ export async function productIdOf(slug, item) {
|
|
|
95
96
|
|
|
96
97
|
/** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
|
|
97
98
|
export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch, version = STRIPE_VERSION, timeout = 15_000 } = {}) {
|
|
99
|
+
await env?.[RECONCILE_GUARD]?.();
|
|
98
100
|
const key = String(env?.STRIPE_KEY ?? '');
|
|
99
101
|
if (!KEY_SHAPE.test(key)) throw new StripeError(0, { error: { message: 'no Stripe key on this Worker', code: 'no-key' } });
|
|
100
102
|
const body = params && method !== 'GET' ? formEncode(params).toString() : null;
|
|
@@ -119,6 +121,7 @@ export async function stripeCall(env, method, path, params = null, { idempotency
|
|
|
119
121
|
error.idempotentReplayed = res.headers.get('idempotent-replayed') === 'true';
|
|
120
122
|
throw error;
|
|
121
123
|
}
|
|
124
|
+
await env?.[RECONCILE_GUARD]?.();
|
|
122
125
|
return json;
|
|
123
126
|
}
|
|
124
127
|
|