@homie-rocks/studio 0.51.2 → 0.51.4
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 +41 -0
- package/app-starters/gathering/app.json +2 -44
- package/app-starters/gathering/index.html +1 -1
- package/app-starters/gathering/src/view.ts +6 -19
- package/apps/APPS.md +55 -1
- package/bin/homie-studio.mjs +5 -1
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +32 -2
- package/guides/office/SKILL.md +24 -0
- package/guides/servers/SKILL.md +24 -0
- package/guides/shop/SKILL.md +8 -0
- package/lib/app-needs.mjs +17 -2
- package/lib/app-ready.mjs +1 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +2 -2
- package/lib/check.mjs +2 -0
- package/lib/decisions.mjs +9 -5
- package/lib/dev.mjs +3 -0
- package/lib/functions-build.mjs +8 -1
- package/lib/shop-links.mjs +4 -2
- package/lib/shop-local.mjs +31 -0
- package/lib/style-presets.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/shop/SHOP.md +6 -0
- package/worker/app-records.mjs +3 -3
- package/worker/arrival.mjs +5 -4
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/index.mjs +7 -0
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/pages.mjs +9 -3
- package/worker/shop-page.mjs +2 -2
- package/worker/shop-subscriptions.mjs +42 -0
- package/worker/site.mjs +9 -7
- package/worker/version.mjs +1 -1
package/lib/decisions.mjs
CHANGED
|
@@ -130,7 +130,7 @@ export const CATALOGUE = Object.freeze([
|
|
|
130
130
|
['style.render', 'style', 'How is the world drawn?', [], (ctx) => {
|
|
131
131
|
if (ctx.drawn?.render && RENDERS[ctx.drawn.render]) return { value: ctx.drawn.render, label: RENDERS[ctx.drawn.render].label, why: 'what the game draws now (its style.json)', options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
132
132
|
const hit = best(RENDERS, ctx);
|
|
133
|
-
const key = hit?.[0] ?? 'lowpoly-flat';
|
|
133
|
+
const key = hit?.[0] ?? (ctx.kind === 'app' ? 'illustrated' : 'lowpoly-flat');
|
|
134
134
|
return { value: key, label: RENDERS[key].label, why: hit ? `your words ${said(ctx, RENDERS[key].words)}: ${RENDERS[key].note}` : `nothing named a look: ${RENDERS[key].note}`, options: Object.entries(RENDERS).map(([k, v]) => ({ id: k, label: v.label, value: k })) };
|
|
135
135
|
}],
|
|
136
136
|
['style.palette', 'style', 'Which colours, and what is each one for?', ['style.render', 'style.light'], (ctx) => {
|
|
@@ -141,6 +141,10 @@ export const CATALOGUE = Object.freeze([
|
|
|
141
141
|
v.ramp = rampFrom(v);
|
|
142
142
|
return { value: v, label: 'The codex\'s own colours', why: 'the Game Codex already names them', options: paletteOptions(ctx) };
|
|
143
143
|
}
|
|
144
|
+
if(ctx.kind === 'app' && !best(PALETTES,ctx)) {
|
|
145
|
+
const value=paletteValue('custom',{bg:ctx.theme.bg??'#f6f3ec',ink:ctx.theme.fg??'#243d36',accent:ctx.theme.accent??'#226b59',accent2:'#b87935'});
|
|
146
|
+
value.ramp=rampFrom(value);return {value,label:'The place’s colours',why:'a readable starting palette for the app; refine it to the organisation',options:paletteOptions(ctx)};
|
|
147
|
+
}
|
|
144
148
|
const hit = best(PALETTES, ctx);
|
|
145
149
|
const key = hit?.[0] ?? ({ gather: 'meadow-morning', brawl: 'candy-pop', race: 'desert-noon', rpg: 'heroic-dawn', shooter: 'space-ink', platformer: 'meadow-morning', party: 'candy-pop', puzzle: 'candy-pop', sports: 'meadow-morning' })[ctx.genre] ?? 'meadow-morning';
|
|
146
150
|
return { value: paletteValue(key), label: paletteLabel({ name: key }), why: hit ? `your words ${said(ctx, PALETTES[key].words)}` : `a ${ctx.genre} game reads best in bright, separate colours`, options: paletteOptions(ctx) };
|
|
@@ -479,7 +483,7 @@ export function setDecision(root, id, did, input, { by = 'ai', why = null, words
|
|
|
479
483
|
* Steer: the person's words, applied where the vocabulary knows them ("warmer", "less saturated", "golden hour",
|
|
480
484
|
* "closer", "chunkier", "add outlines", a hex colour), and always recorded. The decision becomes steered.
|
|
481
485
|
*/
|
|
482
|
-
export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
486
|
+
export function steerDecision(root, id, did, words, { manifest = null, by = 'person' } = {}) {
|
|
483
487
|
const doc = readDecisions(root, id);
|
|
484
488
|
if (!doc) throw new Error(`games/${id} has no decisions yet: homie-studio style init ${id}`);
|
|
485
489
|
const rec = doc.decisions[did];
|
|
@@ -489,7 +493,7 @@ export function steerDecision(root, id, did, words, { manifest = null } = {}) {
|
|
|
489
493
|
if (rec.state === 'locked') return { ok: false, command: 'style steer', id, decision: did, locked: true, why: `${did} is locked: unlock it (with a reason, after the blast radius) before steering it` };
|
|
490
494
|
const { value, applied } = applySteer(did, rec.value, w, doc);
|
|
491
495
|
const steer = [...(rec.steer ?? []), w].slice(-10);
|
|
492
|
-
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : 'steered', why: `
|
|
496
|
+
change(rec, { value, label: did === 'style.palette' ? paletteLabel({ ...value, steered: steer.join(', ') }) : labelOf(did, value), steer, state: rec.state === 'pinned' ? 'pinned' : by === 'person' ? 'steered' : rec.state, why: `refined by ${by === 'person' ? 'the person' : 'the AI'}: "${w}"` }, { by, words: w });
|
|
493
497
|
if (did === 'style.palette' && doc.decisions['style.light'] && doc.decisions['style.light'].state !== 'locked') {
|
|
494
498
|
const L = doc.decisions['style.light'];
|
|
495
499
|
L.value = { ...L.value, ...lightColours(LIGHTS[L.value.time] ?? LIGHTS.morning, value) };
|
|
@@ -762,7 +766,7 @@ export function directionsFor(root, id, { prompt = '' } = {}) {
|
|
|
762
766
|
}
|
|
763
767
|
|
|
764
768
|
/** Pick a direction (all its style values) or Mix rows from several: { 'style.palette': 'b', … }. By the person. */
|
|
765
|
-
export function pickDirection(root, id, choice, { mixes = {}, manifest = null } = {}) {
|
|
769
|
+
export function pickDirection(root, id, choice, { mixes = {}, manifest = null, by: actor = 'person' } = {}) {
|
|
766
770
|
const doc = readDecisions(root, id);
|
|
767
771
|
if (!doc?.board?.directions?.length) throw new Error(`games/${id} has no style board yet: homie-studio style board ${id}`);
|
|
768
772
|
const by = Object.fromEntries(doc.board.directions.map((d) => [d.id, d]));
|
|
@@ -778,7 +782,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
|
|
|
778
782
|
const value = from.values[did];
|
|
779
783
|
if (value === undefined || JSON.stringify(rec.value) === JSON.stringify(value)) continue;
|
|
780
784
|
if (rec.state === 'locked') { refused.push(did); continue; }
|
|
781
|
-
change(rec, { value, label: labelOf(did, value), why:
|
|
785
|
+
change(rec, { value, label: labelOf(did, value), why: `${actor === 'person' ? 'the person' : 'the AI'} picked direction ${from.id.toUpperCase()} on the style board`, state: rec.state === 'pinned' ? 'pinned' : actor === 'person' ? 'steered' : rec.state }, { by: actor });
|
|
782
786
|
changed.push(did);
|
|
783
787
|
}
|
|
784
788
|
doc.board.chosen = choice ?? doc.board.chosen ?? null;
|
package/lib/dev.mjs
CHANGED
|
@@ -209,6 +209,9 @@ export function devConfig(root, remoteAi) {
|
|
|
209
209
|
const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
|
|
210
210
|
mkdirSync(dirname(copy), { recursive: true });
|
|
211
211
|
writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
|
|
212
|
+
const localSecrets=join(root,'.dev.vars'), generatedSecrets=join(dirname(copy),'.dev.vars');
|
|
213
|
+
if(existsSync(localSecrets))writeFileSync(generatedSecrets,readFileSync(localSecrets),{mode:0o600});
|
|
214
|
+
else rmSync(generatedSecrets,{force:true});
|
|
212
215
|
const notes = [
|
|
213
216
|
...(routed ? [`Local dev leaves out the production route${stripped.length === 1 ? '' : 's'} in wrangler.jsonc (${stripped.join(', ') || 'route keys'}): rooms here use this computer's own address, never the live site's. wrangler.jsonc is unchanged.`] : []),
|
|
214
217
|
...(aiChanged ? [remoteAi ? aiNote : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.'] : aiNote ? [aiNote] : []),
|
package/lib/functions-build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {callbackUrl} from '../worker/mcp-events-wire.mjs';
|
|
1
2
|
import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
|
|
2
3
|
import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
|
|
3
4
|
import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
|
|
@@ -12,6 +13,8 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
12
13
|
if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
|
|
13
14
|
for(const [name,feed] of Object.entries(feeds)) {
|
|
14
15
|
if(feed?.webhook)validateWebhook(name,feed.webhook);
|
|
16
|
+
if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
|
|
17
|
+
if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
|
|
15
18
|
if(!/^[a-z][a-z0-9-]*$/.test(name)||!feed||typeof feed.event!=='string'||!feed.event.trim())throw new Error('Event feed needs a name and event type');
|
|
16
19
|
if(feed.event==='record.changed'&&(!/^[a-z][a-z0-9-]*$/.test(feed.app??'')||!/^[a-z][a-z0-9-]*$/.test(feed.collection??'')))throw new Error('Record event feed needs app and collection');
|
|
17
20
|
if(feed.audience!==undefined&&feed.audience!=='owner'&&!(feed.audience&&typeof feed.audience==='object'&&feed.audience.app===feed.app&&typeof feed.audience.role==='string'))throw new Error('Event feed audience must be owner or an app role');
|
|
@@ -51,5 +54,9 @@ export function functionDeploymentSQL(root) {
|
|
|
51
54
|
const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
|
|
52
55
|
for(const [name,feed] of Object.entries(readStudio(root).mcp?.events??{})){definitions.push({name:'@events/'+name,event:feed.event});if(feed.webhook)definitions.push({name:webhookName(name,feed.webhook),event:feed.event});}
|
|
53
56
|
const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
|
|
54
|
-
|
|
57
|
+
const feedNames=Object.keys(readStudio(root).mcp?.events??{}).map(literal).join(',')||"''";
|
|
58
|
+
// Declaration removal also removes private subscription secrets. Runtime studios
|
|
59
|
+
// without event feeds can retain their original no-MCP-storage fast path.
|
|
60
|
+
const cleanup=[`DELETE FROM function_deliveries WHERE function IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM function_subscriptions WHERE name IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames});`];
|
|
61
|
+
return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),...cleanup,`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
|
|
55
62
|
}
|
package/lib/shop-links.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
|
|
|
6
6
|
import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
|
-
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [] }) {
|
|
9
|
+
export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
|
|
10
10
|
const list = async (path, params = {}) => {
|
|
11
11
|
const all = []; let cursor;
|
|
12
12
|
do {
|
|
@@ -19,7 +19,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
19
19
|
} while (cursor);
|
|
20
20
|
return all;
|
|
21
21
|
};
|
|
22
|
-
|
|
22
|
+
if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
|
|
23
|
+
const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
|
|
23
24
|
if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
|
|
24
25
|
const names = JSON.parse(secrets.stdout ?? secrets.out);
|
|
25
26
|
const has = (name) => names.some((s) => s.name === name);
|
|
@@ -134,6 +135,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
134
135
|
portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
|
|
135
136
|
config.portal=portal.login_page?.url;
|
|
136
137
|
}
|
|
138
|
+
if(local)return {ok:true,command:'shop connect',local:true,mode,config};
|
|
137
139
|
const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
|
|
138
140
|
const purchaseSecrets = {};
|
|
139
141
|
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Real Stripe test links, delivered by the owner's Stripe CLI listener. */
|
|
2
|
+
import {readFileSync,writeFileSync,existsSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {stripeProcess} from './stripe-connect.mjs';
|
|
6
|
+
import {syncPaymentLinks} from './shop-links.mjs';
|
|
7
|
+
import {checkShop} from '../worker/shop-rules.mjs';
|
|
8
|
+
import {formEncode,redactStripe} from '../worker/stripe.mjs';
|
|
9
|
+
export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={}) {
|
|
10
|
+
const origin=new URL(url??'http://localhost:8787');
|
|
11
|
+
if(origin.protocol!=='http:' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(origin.hostname))throw new Error('Local Stripe needs an HTTP localhost or Wi-Fi address');
|
|
12
|
+
if(!profile)throw new Error('Name the existing Stripe sandbox CLI profile with --profile');
|
|
13
|
+
const checked=checkShop(JSON.parse(readFileSync(join(root,'shop.json'),'utf8')));
|
|
14
|
+
if(!checked.ok)throw new Error('Fix shop.json before connecting Stripe');
|
|
15
|
+
const api=async(method,path,params={},idempotency)=>{
|
|
16
|
+
const args=['--project-name',profile,method.toLowerCase(),path];
|
|
17
|
+
for(const [k,v] of formEncode(params))args.push('-d',`${k}=${v}`);
|
|
18
|
+
if(idempotency)args.push('--idempotency',idempotency);
|
|
19
|
+
const r=await exec(args,{cwd:root});let body;try{body=JSON.parse(r.stdout);}catch{}
|
|
20
|
+
if(r.code!==0||body?.error||!body)throw new Error('Stripe test request failed: '+redactStripe(body?.error?.message??r.stderr??'no response').slice(0,500));
|
|
21
|
+
if(body.livemode===true||body.data?.some(o=>o.livemode===true))throw new Error('Local checkout refuses live Stripe objects');
|
|
22
|
+
return body;
|
|
23
|
+
};
|
|
24
|
+
const slug='local-'+createHash('sha256').update(root).digest('hex').slice(0,16);
|
|
25
|
+
const r=await syncPaymentLinks({api,shop:checked.shop,slug,site:origin.origin,mode:'test',local:true});
|
|
26
|
+
const file=join(root,'.dev.vars');let vars=existsSync(file)?readFileSync(file,'utf8'):'';
|
|
27
|
+
vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
|
|
28
|
+
vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
|
|
29
|
+
writeFileSync(file,vars,{mode:0o600});
|
|
30
|
+
return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
|
|
31
|
+
}
|
package/lib/style-presets.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
/** How the world is drawn. `family`: the starter library family that fits; `materials`: the material model. */
|
|
8
8
|
export const RENDERS = Object.freeze({
|
|
9
|
+
illustrated: { label: 'Illustrated place', words: /\b(illustrated|illustration|pictorial)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fraunces', body: 'Inter' }, vfx: 'soft', note: 'readable objects, clear typography and purposeful motion for the work of this place' },
|
|
9
10
|
'lowpoly-flat': { label: 'Flat low-poly', words: /\b(low[- ]?poly|cozy|cosy|cute|toy|minimal|clean|simple|wholesome|chill)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fredoka', body: 'Nunito' }, vfx: 'soft', note: 'faceted shapes in flat palette colours: the cheapest look on a phone, the most coherent, and the richest free library' },
|
|
10
11
|
toon: { label: 'Toon / cel', words: /\b(toon|cel|cartoon|anime|comic|neon|cyber|synth|arcade)\b/i, materials: 'toon', outline: true, family: 'kaykit', fonts: { display: 'Lilita One', body: 'Nunito' }, vfx: 'toon', note: 'banded light and ink outlines: reads at any size, loud on a TV' },
|
|
11
12
|
painted: { label: 'Stylised hand-painted', words: /\b(painterly|painted|storybook|watercolou?r|ghibli-like|fairy ?tale|whimsical|hand[- ]painted)\b/i, materials: 'hand-painted', outline: false, family: 'kaykit', fonts: { display: 'Fraunces', body: 'Lora' }, vfx: 'soft', note: 'soft painted albedo with the light baked gently in: warm and storybook' },
|
|
@@ -3624,6 +3624,90 @@
|
|
|
3624
3624
|
}
|
|
3625
3625
|
}
|
|
3626
3626
|
}
|
|
3627
|
+
},
|
|
3628
|
+
"0.51.3": {
|
|
3629
|
+
"files": {
|
|
3630
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3631
|
+
"README.md": "684b3cca8001673c",
|
|
3632
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3633
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3634
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3635
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3636
|
+
"posts/README.md": "37841aae476bd540",
|
|
3637
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3638
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3639
|
+
},
|
|
3640
|
+
"sections": {
|
|
3641
|
+
"AGENTS.md": {
|
|
3642
|
+
"(intro)": "394cbbe934e80722",
|
|
3643
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3644
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3645
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3646
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3647
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3648
|
+
"## Making games": "bb715272fd1a615b",
|
|
3649
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3650
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3651
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3652
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3653
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3654
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3655
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3656
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3657
|
+
"## Beta": "9d02638207a26e73"
|
|
3658
|
+
}
|
|
3659
|
+
},
|
|
3660
|
+
"named": {
|
|
3661
|
+
"Homie Arcade": {
|
|
3662
|
+
"sections": {
|
|
3663
|
+
"AGENTS.md": {
|
|
3664
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
},
|
|
3670
|
+
"0.51.4": {
|
|
3671
|
+
"files": {
|
|
3672
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3673
|
+
"README.md": "684b3cca8001673c",
|
|
3674
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3675
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3676
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3677
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3678
|
+
"posts/README.md": "37841aae476bd540",
|
|
3679
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3680
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3681
|
+
},
|
|
3682
|
+
"sections": {
|
|
3683
|
+
"AGENTS.md": {
|
|
3684
|
+
"(intro)": "394cbbe934e80722",
|
|
3685
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3686
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3687
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3688
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3689
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3690
|
+
"## Making games": "bb715272fd1a615b",
|
|
3691
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3692
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3693
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3694
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3695
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3696
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3697
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3698
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3699
|
+
"## Beta": "9d02638207a26e73"
|
|
3700
|
+
}
|
|
3701
|
+
},
|
|
3702
|
+
"named": {
|
|
3703
|
+
"Homie Arcade": {
|
|
3704
|
+
"sections": {
|
|
3705
|
+
"AGENTS.md": {
|
|
3706
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3627
3711
|
}
|
|
3628
3712
|
}
|
|
3629
3713
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.4",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/shop/SHOP.md
CHANGED
|
@@ -333,3 +333,9 @@ Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
|
|
|
333
333
|
never type card details into a form. Stop on an expired profile and ask the owner
|
|
334
334
|
to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
|
|
335
335
|
dev bindings and disposable studios when finished.
|
|
336
|
+
|
|
337
|
+
## Local Stripe test payments
|
|
338
|
+
|
|
339
|
+
See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
|
|
340
|
+
`shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
|
|
341
|
+
creates a paid order and `order.paid` with the subscription’s original app reference.
|
package/worker/app-records.mjs
CHANGED
|
@@ -97,7 +97,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
97
97
|
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
98
98
|
if (op === 'read') {
|
|
99
99
|
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
|
|
100
|
-
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id
|
|
100
|
+
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id').bind(recordApp, collection).all()).results;
|
|
101
101
|
const records=[];
|
|
102
102
|
for(const r of rows) {
|
|
103
103
|
const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
|
|
@@ -124,11 +124,11 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
124
124
|
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
125
125
|
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
126
126
|
const stmt = op === 'create'
|
|
127
|
-
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at)
|
|
127
|
+
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
128
128
|
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
|
|
129
129
|
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
|
|
130
130
|
const result = await stmt.run();
|
|
131
|
-
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed
|
|
131
|
+
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
|
|
132
132
|
} catch (error) {
|
|
133
133
|
if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
|
|
134
134
|
if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
|
package/worker/arrival.mjs
CHANGED
|
@@ -72,14 +72,14 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
|
|
|
72
72
|
const art = wide || tall
|
|
73
73
|
? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
|
|
74
74
|
: '<div class="arrive-art bare" aria-hidden="true"></div>';
|
|
75
|
-
const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}">
|
|
75
|
+
const html = `<div class="arrive${screen ? ' big' : ''}" data-arrive data-from="${esc(c.from)}"${g?.kind === 'app' ? ' data-app' : ''}>
|
|
76
76
|
${art}<div class="arrive-shade" aria-hidden="true"></div>
|
|
77
77
|
<div class="arrive-in">
|
|
78
78
|
${studio ? `<p class="arrive-kicker">${esc(studio)}</p>` : ''}
|
|
79
79
|
<h1 class="arrive-title">${esc(g?.name ?? '')}</h1>
|
|
80
80
|
${pitch ? `<p class="arrive-pitch">${esc(pitch)}</p>` : ''}
|
|
81
81
|
<div class="arrive-meter" aria-hidden="true"><i data-arrive-bar></i></div>
|
|
82
|
-
<p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step
|
|
82
|
+
<p class="arrive-step" role="status" aria-live="polite"><span data-arrive-step>${g?.kind === 'app' ? 'Opening the app…' : 'Finding a room…'}</span><span class="arrive-room" data-arrive-room></span></p>
|
|
83
83
|
${keys.map(([k, v]) => `<p class="arrive-keys" data-for="${k}">${esc(String(v).slice(0, 160))}</p>`).join('')}
|
|
84
84
|
</div>
|
|
85
85
|
</div>`;
|
|
@@ -166,9 +166,10 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
166
166
|
function later(ms, why) { timers.push(setTimeout(function () { api.lift(why); }, ms)); }
|
|
167
167
|
// Whatever happens, the game gets the screen within 30 s of the page opening.
|
|
168
168
|
later(30000, 'timeout');
|
|
169
|
+
var app=el.getAttribute('data-app') !== null;
|
|
169
170
|
var roomLabel = '';
|
|
170
171
|
// A full server's wait ("you're next for a seat") stays said while its room loads.
|
|
171
|
-
api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
|
|
172
|
+
api.room = function (label) { roomLabel = label || ''; if (a.phase === 'wait') set('wait', a.step, 0.22); else set('game', app ? 'Opening the app…' : 'Loading the game…', 0.22); if (!roomEl.textContent) roomEl.textContent = roomLabel; };
|
|
172
173
|
api.facts = function (text) { if (!api.done && text) roomEl.textContent = text; };
|
|
173
174
|
api.full = function (text) { set('wait', text, 0.3); };
|
|
174
175
|
var attachedAt = 0;
|
|
@@ -185,7 +186,7 @@ export const ARRIVAL_JS = String.raw`(function () {
|
|
|
185
186
|
set('join', 'Joining ' + (roomLabel || 'the room') + '…', 0.36);
|
|
186
187
|
later(15000, 'cap');
|
|
187
188
|
} else if (m.what === 'role' || m.what === 'token') {
|
|
188
|
-
set('world', 'Loading the world…', 0.5);
|
|
189
|
+
set('world', app ? 'Opening the app…' : 'Loading the world…', 0.5);
|
|
189
190
|
// A game built with a helper older than the arrival says nothing more: it has the screen once it has a seat.
|
|
190
191
|
if (a.mode === 'seat' && !seatedTimer) seatedTimer = setTimeout(function () { api.lift('seated'); }, 600);
|
|
191
192
|
} else if (m.what === 'loading') {
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.4","sha256":"6286b3013c1ad438111eaed4338ccbffa2f85bf2a765f9f007155a809c414f71"});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {McpEventStreams} from './mcp-event-streams.mjs';
|
|
2
|
+
import {listEvents} from './mcp-events.mjs';
|
|
3
|
+
import {notification,sseFrame} from './mcp-events-wire.mjs';
|
|
1
4
|
import {allowed,currentCaller} from './mcp-tools.mjs';
|
|
2
5
|
import {settingsOf,launchOf} from './office.mjs';
|
|
3
6
|
|
|
@@ -44,9 +47,10 @@ export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromN
|
|
|
44
47
|
export async function pushEvents(env,cat,origin) {
|
|
45
48
|
if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
|
|
46
49
|
// Page subscriptions, without a studio-wide subscription or delivery cap.
|
|
50
|
+
await env.DB.prepare("DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-stream:%' AND expires<?1").bind(Date.now()).run();
|
|
47
51
|
let after='';const seen=new Set();
|
|
48
52
|
for(;;){
|
|
49
|
-
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'event-sub:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
53
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key LIKE 'event-sub:%' OR key LIKE 'mcp-event-stream:%' OR key LIKE 'tools-stream:%') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
50
54
|
for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
|
|
51
55
|
const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
|
|
52
56
|
if(!response.ok)throw new Error('Event stream wake failed');
|
|
@@ -58,11 +62,13 @@ export async function pushEvents(env,cat,origin) {
|
|
|
58
62
|
/** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
|
|
59
63
|
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
60
64
|
export class EventStreams {
|
|
61
|
-
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
|
|
65
|
+
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
|
|
62
66
|
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
|
|
63
67
|
background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
|
|
64
68
|
async fetch(request) {
|
|
65
69
|
const input=await request.json();
|
|
70
|
+
if(input.op==='extension-stream')return this.extensions.open(input);
|
|
71
|
+
this.extensions.wake(input);
|
|
66
72
|
if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
|
|
67
73
|
if(input.op==='stream') {
|
|
68
74
|
// A reconnect replaces the previous GET, including a half-open socket.
|
|
@@ -78,6 +84,7 @@ export class EventStreams {
|
|
|
78
84
|
const caller=await streamCaller(this.env,connection).catch(()=>null);
|
|
79
85
|
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
80
86
|
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
87
|
+
stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
81
88
|
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
|
|
82
89
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
83
90
|
// hold up the writer's request or a function handler.
|
|
@@ -93,6 +100,8 @@ export class EventStreams {
|
|
|
93
100
|
let caller;
|
|
94
101
|
try{caller=await streamCaller(this.env,input.connection);}
|
|
95
102
|
catch{this.close(stream);return;}
|
|
103
|
+
const eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
104
|
+
if(stream.eventSignature!==eventSignature){stream.eventSignature=eventSignature;stream.controller.enqueue(sseFrame(notification('list_changed',{})));}
|
|
96
105
|
if(input.tools){
|
|
97
106
|
const signature=await this.toolSignature(input.tools,caller);
|
|
98
107
|
if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
|
|
@@ -130,7 +139,7 @@ export class EventStreams {
|
|
|
130
139
|
export async function pushToolList(env,cat,tools,origin) {
|
|
131
140
|
if(!env.LOBBY)return;
|
|
132
141
|
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
133
|
-
const signature=JSON.stringify(publicShape);
|
|
142
|
+
const signature=JSON.stringify([publicShape,eventFeeds(env)]);
|
|
134
143
|
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
135
144
|
if(previous?.value===signature)return;
|
|
136
145
|
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
@@ -10,7 +10,7 @@ export async function readableWebhook(env,cat,feed) {
|
|
|
10
10
|
if((feed.audience??'owner')==='owner')return true;
|
|
11
11
|
return feed.audience?.app===app.id && app.roles?.[feed.audience.role]?.can?.includes('read:'+feed.collection)===true && launchOf(app,await settingsOf(env,{fresh:true}),env)==='public';
|
|
12
12
|
}
|
|
13
|
-
class WebhookFailure extends Error {
|
|
13
|
+
export class WebhookFailure extends Error {
|
|
14
14
|
constructor(message){super(message);this.name='WebhookFailure';}
|
|
15
15
|
}
|
|
16
16
|
export function webhookFailure(error){return error instanceof WebhookFailure?error.message:'Webhook delivery failed';}
|
|
@@ -21,23 +21,28 @@ export async function postWebhook(env,name,event,{fetchFn=globalThis.fetch.bind(
|
|
|
21
21
|
validateWebhook(name,hook);
|
|
22
22
|
const secret=env[hook.secret];
|
|
23
23
|
if(typeof secret!=='string'||!/^whsec_[A-Za-z0-9+/]{43}=$/.test(secret))throw new WebhookFailure('Webhook signing secret unavailable');
|
|
24
|
-
const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
|
|
25
|
-
// Encode the event ID to avoid ambiguous dot-delimited signatures and invalid headers.
|
|
26
24
|
const id='evt_'+btoa(String.fromCharCode(...new TextEncoder().encode(event.id))).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
|
|
25
|
+
return signedWebhook(hook.url,{id,body:{id:event.id,type:event.type,at:event.at,data:event.data},secrets:[secret]},{fetchFn,now,timeoutMs});
|
|
26
|
+
}
|
|
27
|
+
/** Shared transport for configured studio hooks and MCP Events subscriptions. */
|
|
28
|
+
export async function signedWebhook(url,{id,body,secrets,headers={}},{fetchFn=globalThis.fetch.bind(globalThis),now=Date.now,timeoutMs=10000}={}) {
|
|
27
29
|
const timestamp=String(Math.floor(now()/1000));
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
30
|
+
const encoded=JSON.stringify(body),signatures=[];
|
|
31
|
+
for(const secret of secrets){
|
|
32
|
+
const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
|
|
33
|
+
signatures.push('v1,'+btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${encoded}`))))));
|
|
34
|
+
}
|
|
35
|
+
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeoutMs);
|
|
31
36
|
try {
|
|
32
|
-
const response=await fetchFn(
|
|
33
|
-
// Never read or log receiver content: it may echo credentials or private records.
|
|
37
|
+
const response=await fetchFn(url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':signatures.join(' '),...headers},body:encoded});
|
|
34
38
|
await response.body?.cancel();
|
|
35
|
-
if(!response.ok)
|
|
39
|
+
if(!response.ok){const error=new WebhookFailure('Webhook HTTP '+response.status);error.status=response.status;throw error;}
|
|
36
40
|
}catch(error){
|
|
37
41
|
if(error instanceof WebhookFailure)throw error;
|
|
38
42
|
throw new WebhookFailure(controller.signal.aborted?'Webhook timed out':'Webhook network failure');
|
|
39
43
|
}finally{clearTimeout(timer);}
|
|
40
44
|
}
|
|
45
|
+
|
|
41
46
|
export function webhookSubscriptions(env,cat,options={}) {
|
|
42
47
|
return Object.entries(eventFeeds(env)).filter(([,feed])=>feed.webhook).map(([name,feed])=>({
|
|
43
48
|
name:webhookName(name,feed.webhook),event:feed.event,webhook:true,
|
package/worker/functions.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {eventHookSubscriptions} from './mcp-events.mjs';
|
|
1
2
|
import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
|
|
2
3
|
import {parseSchedule,scheduleStatus} from './function-schedule.mjs';
|
|
3
4
|
import {redactText} from '../lib/redact.mjs';
|
|
@@ -15,7 +16,7 @@ export async function syncFunctions(env, definitions = null, now = Date.now()) {
|
|
|
15
16
|
// Deploy owns the production registry; an old in-flight Worker must not overwrite it.
|
|
16
17
|
if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
|
|
17
18
|
const statements=definitions.map(fn=>env.DB.prepare(`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,(SELECT COALESCE(MAX(seq),0) FROM studio_events),?3) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type`).bind(fn.name,fn.event,now));
|
|
18
|
-
statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
|
|
19
|
+
statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
|
|
19
20
|
await env.DB.batch(statements);
|
|
20
21
|
return definitions;
|
|
21
22
|
}
|
|
@@ -33,7 +34,7 @@ export async function pruneEvents(env) {
|
|
|
33
34
|
const cursor=Number((await env.DB.prepare("SELECT value FROM meta WHERE key='function-prune-cursor'").first())?.value??0);
|
|
34
35
|
const rows=(await env.DB.prepare('SELECT seq,id FROM studio_events WHERE seq>?1 ORDER BY seq LIMIT 100').bind(cursor).all()).results;
|
|
35
36
|
const statements=rows.flatMap(({id})=>[
|
|
36
|
-
env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
|
|
37
|
+
env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%' AND function NOT LIKE '@mcp-webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
|
|
37
38
|
env.DB.prepare(`DELETE FROM studio_events WHERE id=?1 AND NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.type=studio_events.type AND s.cursor<studio_events.seq) AND NOT EXISTS (SELECT 1 FROM function_deliveries d WHERE d.event=studio_events.id)`).bind(id),
|
|
38
39
|
]);
|
|
39
40
|
statements.push(env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-prune-cursor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(String(rows.at(-1)?.seq??0)));
|
|
@@ -41,10 +42,11 @@ export async function pruneEvents(env) {
|
|
|
41
42
|
}
|
|
42
43
|
/** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
|
|
43
44
|
export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000, webhookOptions = {}} = {}) {
|
|
45
|
+
if(!env.DB)return;
|
|
44
46
|
definitions = await syncFunctions(env,definitions,now());
|
|
45
|
-
if(!definitions.length)return;
|
|
47
|
+
if(!definitions.length&&!env.STUDIO_EVENT_FEEDS)return;
|
|
46
48
|
if(typeof cat==='function')cat=await cat();
|
|
47
|
-
definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions));
|
|
49
|
+
definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions),env.STUDIO_EVENT_FEEDS?await eventHookSubscriptions(env,cat,{...webhookOptions,now}):[]);
|
|
48
50
|
// An AI notification must not wait behind a slow function handler.
|
|
49
51
|
const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
|
|
50
52
|
const notifications=notify();
|
|
@@ -72,18 +74,21 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
72
74
|
env.DB.prepare("INSERT INTO function_runs(id,function,event,trigger,started,outcome,attempt) SELECT ?1,?2,?3,?4,?5,'running',attempts FROM function_deliveries WHERE event=?3 AND function=?2 AND lease=?1").bind(lease,fn.name,row.event,row.event.startsWith(`schedule:${fn.name}:`)?`schedule: ${JSON.parse(row.data).cron}`:row.type,started),
|
|
73
75
|
]);
|
|
74
76
|
if (!claimed.meta.changes) continue;
|
|
75
|
-
let failure=null;
|
|
77
|
+
let failure=null,permanent=false;
|
|
76
78
|
try {
|
|
77
79
|
const stored=JSON.parse(row.data);
|
|
78
80
|
const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
|
|
79
81
|
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
|
|
80
82
|
const testScope=isolated?isolated[2]:null;
|
|
81
83
|
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
|
|
82
|
-
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
84
|
+
} catch(error) {permanent=error.permanent===true;failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
83
85
|
const finished=now();
|
|
86
|
+
const firstAttempt=fn.mcpEvent?(await env.DB.prepare('SELECT MIN(started) at FROM function_runs WHERE event=?1 AND function=?2').bind(row.event,fn.name).first()).at:finished;
|
|
87
|
+
const abandoned=fn.mcpEvent&&failure!==null&&(permanent||row.attempts+1>=5||finished-firstAttempt>=900000);
|
|
84
88
|
await env.DB.batch([
|
|
85
89
|
failure===null
|
|
86
90
|
? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished)
|
|
91
|
+
: abandoned ? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=?5 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished,failure)
|
|
87
92
|
: env.DB.prepare(`UPDATE function_deliveries SET state='failed',error=?5,due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?failure:'Function failed; inspect its code and retry'),
|
|
88
93
|
env.DB.prepare("UPDATE function_runs SET outcome=?2,finished=?3,error=?4 WHERE id=?1").bind(lease,failure===null?'complete':'failed',finished,failure),
|
|
89
94
|
]);
|
package/worker/index.mjs
CHANGED
|
@@ -607,9 +607,15 @@ function finish(res, path) {
|
|
|
607
607
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
// Repeat after migration: an older isolate may recreate these during a rolling deploy.
|
|
611
|
+
async function removeReferralLeftovers(env) {
|
|
612
|
+
if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
|
|
613
|
+
}
|
|
614
|
+
|
|
610
615
|
export default {
|
|
611
616
|
async scheduled(event, env, ctx) {
|
|
612
617
|
const maintenance=(async () => {
|
|
618
|
+
await removeReferralLeftovers(env);
|
|
613
619
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
614
620
|
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
615
621
|
await reconcileOrders(env, shopOf(cat));
|
|
@@ -619,6 +625,7 @@ export default {
|
|
|
619
625
|
await maintenance;
|
|
620
626
|
},
|
|
621
627
|
async fetch(request, env, ctx) {
|
|
628
|
+
await removeReferralLeftovers(env);
|
|
622
629
|
const url = new URL(request.url);
|
|
623
630
|
await (await import('./functions.mjs')).syncFunctions(env);
|
|
624
631
|
const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {eventSource,eventCaller,checkEvent,eventStart,pollEvents} from './mcp-events.mjs';
|
|
2
|
+
import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseFrame,streamResult,cursorOf} from './mcp-events-wire.mjs';
|
|
3
|
+
|
|
4
|
+
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
|
+
export class McpEventStreams {
|
|
6
|
+
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()=>{}));}
|
|
8
|
+
async write(stream,value,authorize){
|
|
9
|
+
if(stream.closed)return false;
|
|
10
|
+
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
11
|
+
if(stream.closed)return false;
|
|
12
|
+
if(authorize)await authorize();
|
|
13
|
+
try{stream.controller.enqueue(sseFrame(value));return true;}catch{this.close(stream);return false;}
|
|
14
|
+
}
|
|
15
|
+
wake(input){for(const stream of this.streams){if(input.cat)stream.cat=input.cat;this.pump(stream);}}
|
|
16
|
+
pump(stream){if(stream.busy||stream.closed)return;stream.busy=true;this.ctx.waitUntil(this.deliver(stream).catch(()=>this.close(stream)).finally(()=>{stream.busy=false;}));}
|
|
17
|
+
async open(input){
|
|
18
|
+
try{
|
|
19
|
+
const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
|
|
20
|
+
const start=await eventStart(this.env,input.params);
|
|
21
|
+
const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
|
|
22
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+60000).run();
|
|
23
|
+
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
|
+
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
|
+
this.streams.add(stream);
|
|
26
|
+
// Heartbeats also recheck authorization, catch missed wakes and renew discovery.
|
|
27
|
+
stream.timer=setInterval(()=>this.pump(stream),15000);
|
|
28
|
+
this.pump(stream);
|
|
29
|
+
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
30
|
+
}catch(error){return Response.json({jsonrpc:'2.0',id:input.id,error:eventError(error)});}
|
|
31
|
+
}
|
|
32
|
+
async deliver(stream){
|
|
33
|
+
try{
|
|
34
|
+
let more=true;
|
|
35
|
+
while(more&&!stream.closed){
|
|
36
|
+
const caller=await eventCaller(this.env,stream.connection);
|
|
37
|
+
const feed=await checkEvent(this.env,stream.cat,caller,stream.params);
|
|
38
|
+
if(stream.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});
|
|
39
|
+
const start=await eventStart(this.env,{name:stream.params.name,cursor:stream.cursor});
|
|
40
|
+
if(start.truncated){stream.cursor=cursorOf(stream.params.name,start.after);await this.write(stream,notification('active',{cursor:stream.cursor,truncated:true},stream.id));}
|
|
41
|
+
const page=await pollEvents(this.env,stream.cat,caller,{...stream.params,maxAgeMs:undefined,cursor:stream.cursor});
|
|
42
|
+
for(const event of page.events){
|
|
43
|
+
await checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params);
|
|
44
|
+
if(!await this.write(stream,notification('event',event,stream.id),async()=>checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params)))return;
|
|
45
|
+
stream.cursor=event.cursor;
|
|
46
|
+
}
|
|
47
|
+
stream.cursor=page.cursor;more=page.hasMore;
|
|
48
|
+
}
|
|
49
|
+
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+60000).run();
|
|
50
|
+
await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
|
|
51
|
+
}catch(error){
|
|
52
|
+
const details=eventError(error);
|
|
53
|
+
if(terminalEventError(details.code)){
|
|
54
|
+
await this.write(stream,notification('terminated',{error:details},stream.id));await this.write(stream,streamResult(stream.id));this.close(stream);
|
|
55
|
+
}else await this.write(stream,notification('error',{error:details},stream.id));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|