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 package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stacki",
"productName": "Stacki",
"version": "0.1.60",
"version": "0.1.61",
"description": "Stacki — a visual Astro builder with live preview and publishing.",
"homepage": "https://github.com/flowtricks/stacki",
"main": "dist/electron/main.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-stacki/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-stacki",
"version": "0.1.60",
"version": "0.1.61",
"description": "Add Stacki Studio to an existing Astro project",
"type": "module",
"bin": {
Expand Down
8 changes: 7 additions & 1 deletion studio/docs/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ source distribution does not create resources or claim that an account is config

- Website dependencies are installed into the deployment image and reused when the repository lockfile matches. Changed lockfiles use a cached install fallback. Ordinary cold starts restore drafts and start Astro without reinstalling packages.
- One shared basic Container; three-minute sleep timeout after connections close.
- The editor pauses after five minutes without interaction once saves and requests finish. Pausing removes the preview iframe and closes collaboration connections. Resume reconnects the draft and obtains a fresh preview login.
- The editor pauses when its tab is hidden, or after five minutes without interaction,
once saves and requests finish. Pausing removes preview iframes and closes collaboration
connections before background tabs can freeze their timers. Resume reconnects the draft
and obtains a fresh preview login.
- Connections without incoming application traffic expire after 90 seconds, including
previews and clients that never answered a heartbeat. Outgoing heartbeats cannot renew
their own lease and keep an abandoned container running.
- GitHub sync checks are sequential, at most once per minute per active editor, and skipped in hidden tabs.
- Collaboration broadcasts and local previews update immediately. Durable snapshots batch over two seconds; unchanged snapshots are not uploaded again. Saved is acknowledged only after persistence succeeds.
- Reconnection uses exponential backoff up to a minute; edit queues are bounded.
Expand Down
4 changes: 2 additions & 2 deletions studio/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion studio/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stacki/studio",
"version": "0.1.60",
"version": "0.1.61",
"type": "module",
"license": "MIT",
"engines": {
Expand Down
24 changes: 12 additions & 12 deletions studio/src/features/collaboration/connectionLease.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IncomingMessage } from 'node:http';
import { WebSocket, type RawData } from 'ws';
import { WebSocket } from 'ws';

export function reauthorizeConnection(
request: IncomingMessage,
Expand Down Expand Up @@ -33,8 +33,10 @@ export function maintainConnection(
authorize?: () => Promise<boolean>,
options: { readonly checkEvery?: number; readonly pingEvery?: number } = {},
) {
let alive = true;
let supportsHeartbeat = false;
const pingEveryMs = options.pingEvery ?? 30_000;
// Allow three heartbeat intervals for browser throttling and network jitter.
const silenceMsMax = pingEveryMs * 3;
let receivedAt = Date.now();
let lease: ReturnType<typeof setTimeout> | undefined;
const check = async () => {
try {
Expand All @@ -52,25 +54,23 @@ export function maintainConnection(
if (authorize) {
lease = setTimeout(() => void check(), options.checkEvery ?? 270_000);
}
const received = (data: RawData) => {
alive = true;
if (String(data) === '{"type":"pong"}') {
supportsHeartbeat = true;
}
const received = () => {
receivedAt = Date.now();
};
// Protocol ping/pong terminates at Cloudflare's proxy. Application frames
// exercise every hop through to the editor and renew proxy activity.
// exercise every hop. Vite sends ping frames instead of replying with pong.
// Never exempt clients that have not replied: an orphaned preview otherwise
// renews the container's activity forever through our outgoing heartbeats.
socket.on('message', received);
const heartbeat = setInterval(() => {
if (!alive && supportsHeartbeat) {
if (Date.now() - receivedAt >= silenceMsMax) {
socket.terminate();
return;
}
alive = false;
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}, options.pingEvery ?? 30_000);
}, pingEveryMs);
socket.once('close', () => {
clearTimeout(lease);
clearInterval(heartbeat);
Expand Down
21 changes: 20 additions & 1 deletion studio/src/features/session/activity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
// Read by the idle timer so a build, upload, or save is never interrupted.
const pending = new Set<symbol>();
const settled = new Set<() => void>();
export function beginOperation() {
const token = Symbol('operation');
pending.add(token);
return () => pending.delete(token);
return () => {
const removed = pending.delete(token);
if (removed && pending.size === 0) {
for (const listener of settled) {
listener();
}
}
return removed;
};
}
export function onOperationsSettled(listener: () => void) {
// Only mounted editor sessions subscribe; cleanup releases each registration.
if (settled.size >= 30) {
throw new Error('Too many idle session listeners');
}
settled.add(listener);
return () => {
settled.delete(listener);
};
}
export function hasPendingOperations() {
return pending.size > 0;
Expand Down
25 changes: 22 additions & 3 deletions studio/src/features/session/useIdleSession.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { hasPendingOperations, mayPause } from './activity';
import { hasPendingOperations, mayPause, onOperationsSettled } from './activity';

export function useIdleSession(canPause: boolean) {
const [paused, setPaused] = useState(false);
const lastActivity = useRef(Date.now());
const safe = useRef(canPause);
safe.current = canPause;
useEffect(() => {
if (canPause && document.hidden && !hasPendingOperations()) {
setPaused(true);
}
}, [canPause]);
useEffect(() => {
const activity = () => {
lastActivity.current = Date.now();
Expand All @@ -14,13 +19,27 @@ export function useIdleSession(canPause: boolean) {
for (const event of events) {
window.addEventListener(event, activity, { passive: true });
}
const timer = setInterval(() => {
const check = () => {
if (!safe.current || hasPendingOperations()) {
return;
}
// Hidden tabs can freeze their timers while proxy sockets stay alive.
// Release connections before freezing, but only after work is saved.
if (document.hidden) {
setPaused(true);
return;
}
if (mayPause(lastActivity.current, Date.now(), !safe.current || hasPendingOperations())) {
setPaused(true);
}
}, 10_000);
};
document.addEventListener('visibilitychange', check);
const stopSettled = onOperationsSettled(check);
const timer = setInterval(check, 10_000);
return () => {
clearInterval(timer);
stopSettled();
document.removeEventListener('visibilitychange', check);
for (const event of events) {
window.removeEventListener(event, activity);
}
Expand Down
2 changes: 1 addition & 1 deletion studio/src/shared/release.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": "0.1.60"
"version": "0.1.61"
}
34 changes: 33 additions & 1 deletion studio/test/budget.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Goal: save batching and idle controls bound hosting use without dropping pending work.
// Method: exercise persistence failures, operation completion, and idle-time boundaries.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BatchedWriter } from '../src/features/storage/batchedWriter';
import { beginOperation, hasPendingOperations, mayPause } from '../src/features/session/activity';
import {
beginOperation,
hasPendingOperations,
mayPause,
onOperationsSettled,
} from '../src/features/session/activity';

test('many edits share one durable snapshot and acknowledge only after storage succeeds', async () => {
const writes: Uint8Array[] = [];
Expand Down Expand Up @@ -72,6 +79,31 @@ test('idle sessions pause after five minutes, but pending work prevents pause',
assert.equal(hasPendingOperations(), false);
});

test('hidden sessions can pause after the last operation without waiting for a browser timer', () => {
let calls = 0;
const stop = onOperationsSettled(() => {
assert.equal(hasPendingOperations(), false);
calls++;
});
const first = beginOperation();
const second = beginOperation();
try {
first();
assert.equal(calls, 0, 'the remaining save still prevents pause');
second();
assert.equal(calls, 1, 'settling the final operation wakes the idle check');
second();
assert.equal(calls, 1, 'repeated cleanup does not emit another completion');
stop();
beginOperation()();
assert.equal(calls, 1, 'unmounted sessions no longer receive completions');
} finally {
first();
second();
stop();
}
});

test('asset totals reject uploads before durable storage grows past the allowance', async () => {
const { mkdtemp, mkdir, writeFile, truncate, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
Expand Down
82 changes: 65 additions & 17 deletions studio/test/connection-lease.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Goal: live peers remain connected, while revoked and abandoned peers release compute.
// Method: drive real WebSocket pairs with bounded heartbeat intervals and deadlines.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
Expand Down Expand Up @@ -77,21 +79,67 @@ async function connectedPair() {
return { server, client, socket };
}

test('a client that stops responding is disconnected even when the transport remains open', async () => {
const { server, client, socket } = await connectedPair();
client.once('message', () => client.send(JSON.stringify({ type: 'pong' })));
maintainConnection(socket, undefined, { pingEvery: 15 });
await once(client, 'close');
assert.equal(client.readyState, WebSocket.CLOSED);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
test(
'a client that stops responding is disconnected even when the transport remains open',
{
timeout: 5000,
},
async () => {
const { server, client, socket } = await connectedPair();
client.once('message', () => client.send(JSON.stringify({ type: 'pong' })));
maintainConnection(socket, undefined, { pingEvery: 15 });
await once(client, 'close');
assert.equal(client.readyState, WebSocket.CLOSED);
await new Promise<void>((resolve) => server.close(() => resolve()));
},
);

test('an editor opened before heartbeat support can remain connected until it refreshes', async () => {
const { server, client, socket } = await connectedPair();
maintainConnection(socket, undefined, { pingEvery: 10 });
await delay(60);
assert.equal(client.readyState, WebSocket.OPEN);
client.close();
await once(client, 'close');
await new Promise<void>((resolve) => server.close(() => resolve()));
});
test(
'a client that never replies cannot keep a container awake indefinitely',
{
timeout: 5000,
},
async () => {
const { server, client, socket } = await connectedPair();
let pings = 0;
client.on('message', () => pings++);
try {
maintainConnection(socket, undefined, { pingEvery: 30 });
await once(client, 'close');
assert.equal(client.readyState, WebSocket.CLOSED);
assert.ok(pings <= 3, 'outgoing heartbeats cannot renew their own lease');
} finally {
client.terminate();
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);

test(
'Vite ping messages renew the lease without a document-style pong response',
{
timeout: 5000,
},
async () => {
const { server, client, socket } = await connectedPair();
let pings = 0;
client.on('message', () => {
pings++;
client.send(JSON.stringify({ type: 'ping' }));
});
try {
maintainConnection(socket, undefined, { pingEvery: 100 });
const deadline = Date.now() + 3000;
while (pings < 5 && Date.now() < deadline) {
await delay(25);
}
assert.ok(pings >= 5, 'preview traffic survives beyond the initial lease');
assert.equal(client.readyState, WebSocket.OPEN);
} finally {
client.terminate();
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);
Loading
Loading