Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions extensions/code-tutor/canvas-kit/.kit-version.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "2026-07-05.1",
"syncedAt": "2026-07-06T05:14:31.801Z",
"version": "2026-07-07.6",
"syncedAt": "2026-07-08T01:54:39.058Z",
"source": "create-canvas-app/kit"
}
107 changes: 74 additions & 33 deletions extensions/code-tutor/canvas-kit/client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,25 +85,21 @@ export function pollWhileVisible(tick, seconds, { whenVisible = true, immediate
}

/**
* Mount a canvas view and keep it live.
* @param {object} opts
* @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view
* Returns an htm/Preact vnode. Re-invoked on every state push.
* @param {HTMLElement} [opts.mount] defaults to #app or <body>
* @param {(state:any)=>void} [opts.onState]
* @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh.
* For an interval bound to live state, use `pollWhileVisible` in a useEffect instead.
* @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}}
* DOM-FREE transport for a canvas: owns the loopback wiring (GET /state, GET
* /events SSE, POST /action) and the derived `state`/`connected`, with no Preact
* and no DOM. `mountCanvas` composes this with a render loop; keeping it separate
* makes the reconnect/invoke glue unit-testable without a browser (see
* test/client.test.mjs). Both callbacks receive the latest `(state, connected)`.
* @param {object} [opts]
* @param {(state:any, connected:boolean)=>void} [opts.onState] fired on the initial /state and every SSE push
* @param {(connected:boolean)=>void} [opts.onConnected] fired when the SSE stream opens/errors
* @param {typeof EventSource} [opts.EventSourceImpl] override the SSE impl (tests); defaults to the global
* @returns {{invoke:Function, refresh:Function, get state():any, get connected():boolean}}
*/
export function mountCanvas({ view, mount, onState, poll } = {}) {
const root = mount || document.getElementById("app") || document.body;
// Preact's render() diffs against — but does not clear — pre-existing DOM in
// the container, so a static no-JS placeholder (e.g. <p>Loading…</p> in the
// HTML shell) would linger as a sibling. Clear it once so Preact owns an empty
// root; the view's own loading branch covers the gap until first state.
root.replaceChildren();
export function connectCanvas({ onState, onConnected, EventSourceImpl } = {}) {
let state = null;
let connected = false;
const ES = EventSourceImpl || (typeof EventSource !== "undefined" ? EventSource : null);

async function invoke(actionName, input) {
const res = await fetch("./action", {
Expand All @@ -118,41 +114,86 @@ export function mountCanvas({ view, mount, onState, poll } = {}) {
return data.result;
}

function rerender() {
render(view({ state, invoke, connected }), root);
}

async function refresh() {
try {
state = await (await fetch("./state")).json();
onState?.(state);
rerender();
onState?.(state, connected);
} catch { /* offline; SSE will recover */ }
}

function connect() {
const es = new EventSource("./events");
if (!ES) return; // no EventSource (e.g. a non-browser host); refresh() still works
const es = new ES("./events");
es.onmessage = (e) => {
let next;
try {
next = JSON.parse(e.data);
} catch {
return; // ignore a malformed SSE frame; the next push recovers
}
// Update + render OUTSIDE the try so a bug in onState/the view surfaces as
// a real error instead of being silently mislabeled a "malformed frame".
// Update OUTSIDE the try so a bug in onState surfaces as a real error
// instead of being silently mislabeled a "malformed frame".
state = next;
connected = true;
onState?.(state);
rerender();
onState?.(state, connected);
};
es.onopen = () => { connected = true; rerender(); };
es.onerror = () => { connected = false; rerender(); /* EventSource auto-reconnects */ };
es.onopen = () => { connected = true; onConnected?.(connected); };
es.onerror = () => { connected = false; onConnected?.(connected); /* EventSource auto-reconnects */ };
}

refresh();
connect();

return {
invoke,
refresh,
get state() { return state; },
get connected() { return connected; },
};
}

/**
* Mount a canvas view and keep it live.
* @param {object} opts
* @param {(model:{state:any, invoke:Function, connected:boolean})=>any} opts.view
* Returns an htm/Preact vnode. Re-invoked on every state push.
* @param {HTMLElement} [opts.mount] defaults to #app or <body>
* @param {(state:any)=>void} [opts.onState]
* @param {PollOptions} [opts.poll] built-in fixed-interval visibility-gated auto-refresh.
* For an interval bound to live state, use `pollWhileVisible` in a useEffect instead.
* @returns {{invoke:Function, refresh:Function, stopPoll:Function, get state():any}}
*/
export function mountCanvas({ view, mount, onState, poll } = {}) {
const root = mount || document.getElementById("app") || document.body;
// Preact's render() diffs against — but does not clear — pre-existing DOM in
// the container, so a static no-JS placeholder (e.g. <p>Loading…</p> in the
// HTML shell) would linger as a sibling. Clear it once so Preact owns an empty
// root; the view's own loading branch covers the gap until first state.
root.replaceChildren();

let latestState = null;
let latestConnected = false;
let client;

function rerender() {
render(view({ state: latestState, invoke: client.invoke, connected: latestConnected }), root);
}

// The transport is DOM-free (connectCanvas); this wrapper only adds the Preact
// render on each state/connection change.
client = connectCanvas({
onState: (state, connected) => {
latestState = state;
latestConnected = connected;
onState?.(state);
rerender();
},
onConnected: (connected) => {
latestConnected = connected;
rerender();
},
});

// Built-in fixed-interval auto-refresh, delegating to the shared
// visibility-gated primitive. Pass `poll: { action, seconds, immediate }`.
//
Expand All @@ -164,7 +205,7 @@ export function mountCanvas({ view, mount, onState, poll } = {}) {
// @property {boolean} [immediate=false] fire one tick right after mount
function startPoll({ action, seconds, input, whenVisible = true, immediate = false } = {}) {
return pollWhileVisible(
() => (action ? invoke(action, input) : refresh()),
() => (action ? client.invoke(action, input) : client.refresh()),
seconds,
{ whenVisible, immediate }
);
Expand All @@ -174,10 +215,10 @@ export function mountCanvas({ view, mount, onState, poll } = {}) {
if (poll) stopPoll = startPoll(poll);

return {
invoke,
refresh,
invoke: client.invoke,
refresh: client.refresh,
stopPoll: () => stopPoll(),
get state() { return state; },
get state() { return latestState; },
};
}

Expand Down
215 changes: 215 additions & 0 deletions extensions/code-tutor/canvas-kit/github-store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// canvas-kit/github-store.mjs
//
// A SHARED, multi-writer durable store backed by a file in a GitHub repository.
// Where userStore/sessionStore/workspaceStore (storage.mjs) persist to local disk
// — private to one machine — githubStore persists the same JSON to a file in a
// repo via the Contents API, so every collaborator who can push to that repo edits
// ONE shared document. GitHub is the backing store AND the access-control layer
// (invite collaborators to a private repo); there is no server to run.
//
// It exposes the same { load, save } shape the runtime's loadState/saveState
// expect, plus poll() for cheap change-detection so a canvas can pull other
// people's edits live (wire it to server.mjs's syncState + syncIntervalMs).
//
// Concurrency: every write carries the blob sha it read (optimistic lock). A
// concurrent commit makes the PUT 409; save() re-reads to refresh the sha and
// retries. The default policy is last-writer-wins for the whole document; pass a
// merge(remoteState, myState) to resolve conflicts field-by-field instead (e.g.
// union a board's concerns by id). Reads use an ETag If-None-Match so an unchanged
// poll is a cheap 304 with no body.
//
// Token: resolved once from opts.token (string | async () => string), then
// GH_TOKEN / GITHUB_TOKEN, then `gh auth token`. Needs the `repo` scope for a
// private repo. The token is only ever sent as an Authorization header — never
// logged, never written to the repo.

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

const API = "https://api.github.com";
const UA = "canvas-kit-github-store";
// Hard deadline for every GitHub API call so a hung request (stalled TLS, dropped
// connection) can't freeze a user action or dead-lock the sync loop (mirrors the
// AbortSignal.timeout pattern in net.mjs safeFetch).
const DEFAULT_TIMEOUT_MS = 15000;
// Refuse an implausibly large state file rather than allocate it on every poll. A
// board's JSON is tens of KB; this is a safety ceiling, not a real size limit.
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// Bounded save retries on an optimistic-lock conflict (409) before failing loud.
const MAX_SAVE_RETRIES = 3;

// Resolve a GitHub token lazily and memoize it. A 401 (expired/rotated) clears the
// cache via invalidate() so the next call re-resolves.
function makeTokenResolver(tokenOpt) {
let cached = null;
async function fromGhCli() {
try {
const { stdout } = await execFileAsync("gh", ["auth", "token"], { windowsHide: true });
return String(stdout).trim() || null;
} catch {
return null; // gh missing or not logged in → fall through to the no-token error
}
}
return {
async get() {
if (cached) return cached;
let t = null;
if (typeof tokenOpt === "function") t = await tokenOpt();
else if (typeof tokenOpt === "string" && tokenOpt) t = tokenOpt;
if (!t) t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
if (!t) t = await fromGhCli();
if (!t) throw new Error("githubStore: no GitHub token (set GH_TOKEN or run `gh auth login`)");
cached = t;
return cached;
},
invalidate() { cached = null; },
};
}

function encodePath(path) {
// Keep the slashes as path separators in the Contents API URL; encode each
// segment so spaces / unicode in a filename can't break the request.
return String(path).split("/").filter(Boolean).map(encodeURIComponent).join("/");
}

/**
* A GitHub-repo-backed durable store: one JSON file, many writers.
* @param {object} opts
* @param {string} opts.owner repo owner (user or org)
* @param {string} opts.repo repo name
* @param {string} opts.path path to the JSON file in the repo (e.g. "state/board.json")
* @param {string} [opts.branch="main"]
* @param {string|(()=>Promise<string>|string)} [opts.token] token or async resolver
* @param {(remote:any,mine:any)=>any} [opts.merge] conflict resolver (default: last-writer-wins)
* @param {(state:any)=>string} [opts.message] commit message from the state (default: a timestamp)
* @param {string} [opts.apiBase=API]
* @returns {{file:string, load:(fallback?:any)=>Promise<any>, poll:()=>Promise<{changed:boolean,state?:any}>, save:(state:any)=>Promise<void>}}
*/
export function githubStore(opts) {
const { owner, repo, path, branch = "main", token, merge, message, apiBase = API } = opts ?? {};
if (!owner || !repo || !path) throw new Error("githubStore: owner, repo and path are required");
// apiBase is a trusted, fixed host by design — GitHub's public API by default, or a
// GitHub Enterprise host the canvas author sets at construction (never runtime
// input). That's why we call fetch() directly rather than the kit's safeFetch SSRF
// guard, which would add a DNS-resolution check on every call for a host that can't
// vary per request. Still, require https for any custom apiBase as defense-in-depth.
if (apiBase !== API) {
let base;
try { base = new URL(apiBase); } catch { throw new Error("githubStore: apiBase must be a valid URL"); }
if (base.protocol !== "https:") throw new Error("githubStore: apiBase must be https");
}

const tok = makeTokenResolver(token);
const contentsUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePath(path)}`;
const label = `${owner}/${repo}:${path}`;

let sha = null; // last-seen blob sha — sent on write as the optimistic lock
let etag = null; // last-seen ETag — sent on poll as If-None-Match
let lastText = null; // last-seen decoded content — guards against no-op broadcasts

async function api(url, init = {}, allow = []) {
const t = await tok.get();
const res = await fetch(url, {
...init,
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
headers: {
Authorization: `Bearer ${t}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
...(init.headers ?? {}),
},
});
// An expired token surfaces as 401 — drop the memoized token so a retry can
// re-resolve (e.g. after `gh auth refresh`), then surface the failure.
if (res.status === 401) { tok.invalidate(); }
// ok, or an expected status the caller handles (404 fresh-file, 304 unchanged,
// 409 write conflict), pass through; anything else is a hard error.
if (res.ok || allow.includes(res.status)) return res;
const body = await res.text().catch(() => "");
throw new Error(`githubStore ${label}: ${init.method ?? "GET"} ${res.status} ${body.slice(0, 200)}`);
}

function decode(json) {
// Contents API returns base64 (wrapped at 60 cols); Buffer handles the newlines.
return Buffer.from(json.content ?? "", "base64").toString("utf8");
}

async function readJson(res) {
// Bound the allocation: refuse an oversized response before reading it, so a
// huge state file can't be pulled into memory on every poll tick.
const len = Number(res.headers.get("content-length") || 0);
if (len > MAX_RESPONSE_BYTES) {
throw new Error(`githubStore ${label}: response too large (${len} bytes)`);
}
return res.json();
}

async function load(fallback = null) {
const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, {}, [404]);
if (res.status === 404) { sha = null; etag = null; lastText = null; return fallback; }
const json = await readJson(res);
sha = json.sha ?? null;
etag = res.headers.get("etag");
const text = decode(json);
lastText = text;
return text.trim() ? JSON.parse(text) : fallback;
}

async function poll() {
const headers = etag ? { "If-None-Match": etag } : {};
const res = await api(`${contentsUrl}?ref=${encodeURIComponent(branch)}`, { headers }, [304, 404]);
if (res.status === 304) return { changed: false };
if (res.status === 404) {
if (sha === null && lastText === null) return { changed: false };
sha = null; etag = null; lastText = null;
return { changed: true, state: null };
}
const json = await readJson(res);
const text = decode(json);
sha = json.sha ?? null;
etag = res.headers.get("etag");
if (text === lastText) return { changed: false };
lastText = text;
return { changed: true, state: text.trim() ? JSON.parse(text) : null };
}

async function save(state) {
let toWrite = state;
for (let attempt = 0; ; attempt++) {
const text = JSON.stringify(toWrite, null, 2);
const body = {
message: (message ? message(toWrite) : `Update ${path}`) || `Update ${path}`,
content: Buffer.from(text, "utf8").toString("base64"),
branch,
...(sha ? { sha } : {}),
};
const res = await api(contentsUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, [409, 404]);
if (res.ok) {
const json = await res.json();
sha = json.content?.sha ?? null;
etag = null; // PUT's ETag isn't the content GET's — force a fresh poll baseline
lastText = text; // our own write must not read back as a change
return;
}
// 409 (or a 404 if the file/branch vanished): the sha we held is stale.
// Re-read to refresh sha, optionally merge the remote with our intended
// write, and retry. Bounded so a persistent conflict fails loudly.
if ((res.status === 409 || res.status === 404) && attempt < MAX_SAVE_RETRIES) {
const remote = await load(null);
toWrite = merge ? merge(remote, state) : state; // default: last-writer-wins
continue;
}
const errBody = await res.text().catch(() => "");
throw new Error(`githubStore ${label}: save failed ${res.status} ${errBody.slice(0, 200)}`);
}
}

return { file: label, load, poll, save };
}
Loading
Loading