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
127 changes: 127 additions & 0 deletions apps/web/src/lib/server/playground-db.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'playground-test-'))
const {
createSession,
getSession,
waitForBrowserSyncResult,
waitForErrorReport,
updateShader,
setScreenshot,
setErrors,
setStructuredErrors,
recordErrorReport,
setUniformValues,
updateMetadata,
} = await import('./playground-db.ts')
Expand Down Expand Up @@ -172,6 +175,130 @@ runTest('setStructuredErrors stores structured errors', () => {
assert.deepEqual(session.structuredErrors, errors)
})

runTest('waitForErrorReport resolves when browser posts errors', async () => {
const { id } = createSession()
const wait = waitForErrorReport(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: ['shader failed'],
structuredErrors: [],
})
}, 20)

const report = await wait
assert.deepEqual(report, {
errors: ['shader failed'],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult resolves early on compilation errors', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: ['compile failed'],
structuredErrors: [],
})
}, 20)

const result = await wait
assert.equal(Date.now() - startedAt < 500, true)
assert.equal(result.screenshotBase64, null)
assert.deepEqual(result.errorReport, {
errors: ['compile failed'],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult waits for screenshot after a successful empty error report', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [],
})
}, 20)

setTimeout(() => {
setScreenshot(id, 'data:image/png;base64,ok')
}, 60)

const result = await wait
const elapsedMs = Date.now() - startedAt

assert.equal(elapsedMs >= 40, true)
assert.equal(elapsedMs < 500, true)
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult waits for the error-clear report after screenshot success', async () => {
const { id } = createSession()
const startedAt = Date.now()
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
setScreenshot(id, 'data:image/png;base64,ok')
}, 20)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [],
})
}, 60)

const result = await wait
const elapsedMs = Date.now() - startedAt

assert.equal(elapsedMs >= 40, true)
assert.equal(elapsedMs < 500, true)
assert.equal(result.screenshotBase64, 'data:image/png;base64,ok')
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [],
})
})

runTest('waitForBrowserSyncResult returns nulls when the browser never responds', async () => {
const { id } = createSession()
const result = await waitForBrowserSyncResult(id, 25)

assert.deepEqual(result, {
screenshotBase64: null,
errorReport: null,
})
})

runTest('waitForBrowserSyncResult treats structured errors as compilation failures', async () => {
const { id } = createSession({ language: 'tsl', tslSource: 'export function createMaterial() {}' })
const wait = waitForBrowserSyncResult(id, 1_000)

setTimeout(() => {
recordErrorReport(id, {
errors: [],
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
})
}, 20)

const result = await wait
assert.equal(result.screenshotBase64, null)
assert.deepEqual(result.errorReport, {
errors: [],
structuredErrors: [{ kind: 'tsl-runtime', message: 'material build failed' }],
})
})

runTest('setUniformValues stores values', () => {
const { id } = createSession()
const values = { uTime: 1.5, uColor: [1, 0, 0] }
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/lib/server/playground-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ const sseConnections = new Map<string, Set<WritableStreamDefaultWriter<Uint8Arra
// Screenshot wait queue: when an update is posted, the API waits for the
// browser to send a screenshot back. This map stores resolve callbacks.
const screenshotWaiters = new Map<string, Array<(base64: string | null) => void>>()
const errorReportWaiters = new Map<string, Array<(report: ErrorReportPayload | null) => void>>()

export type ErrorReportPayload = {
errors: string[]
structuredErrors: PlaygroundError[]
}

export function addSSEConnection(sessionId: string, writer: WritableStreamDefaultWriter<Uint8Array>) {
let set = sseConnections.get(sessionId)
Expand Down Expand Up @@ -203,6 +209,85 @@ export function resolveScreenshotWaiters(sessionId: string, base64: string) {
}
}

export function waitForErrorReport(sessionId: string, timeoutMs: number): Promise<ErrorReportPayload | null> {
return new Promise((resolve) => {
const list = errorReportWaiters.get(sessionId) ?? []
list.push(resolve)
errorReportWaiters.set(sessionId, list)
setTimeout(() => {
const current = errorReportWaiters.get(sessionId)
if (current) {
const idx = current.indexOf(resolve)
if (idx !== -1) {
current.splice(idx, 1)
if (current.length === 0) errorReportWaiters.delete(sessionId)
}
}
resolve(null)
}, timeoutMs)
})
}

export function resolveErrorReportWaiters(sessionId: string, report: ErrorReportPayload) {
const list = errorReportWaiters.get(sessionId)
if (!list || list.length === 0) return
errorReportWaiters.delete(sessionId)
for (const resolve of list) {
resolve(report)
}
}

export async function waitForBrowserSyncResult(sessionId: string, timeoutMs: number): Promise<{
screenshotBase64: string | null
errorReport: ErrorReportPayload | null
}> {
const screenshotPromise = waitForScreenshot(sessionId, timeoutMs).then((base64) => ({
type: 'screenshot' as const,
base64,
}))
const errorPromise = waitForErrorReport(sessionId, timeoutMs).then((report) => ({
type: 'errorReport' as const,
report,
}))

let waitForScreenshotEvent = true
let waitForErrorEvent = true
let screenshotBase64: string | null = null
let errorReport: ErrorReportPayload | null = null

while (waitForScreenshotEvent || waitForErrorEvent) {
const pending: Array<
Promise<
| { type: 'screenshot'; base64: string | null }
| { type: 'errorReport'; report: ErrorReportPayload | null }
>
> = []

if (waitForScreenshotEvent) pending.push(screenshotPromise)
if (waitForErrorEvent) pending.push(errorPromise)

const next = await Promise.race(pending)

if (next.type === 'screenshot') {
waitForScreenshotEvent = false
screenshotBase64 = next.base64
} else {
waitForErrorEvent = false
errorReport = next.report
}

const hasCompilationErrors = !!errorReport
&& (errorReport.errors.length > 0 || errorReport.structuredErrors.length > 0)
const hasSuccessfulSync = screenshotBase64 !== null && errorReport !== null

if (hasCompilationErrors || hasSuccessfulSync) {
break
}
}

return { screenshotBase64, errorReport }
}

// ---------------------------------------------------------------------------
// Row type from SQLite
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -354,6 +439,12 @@ export function setStructuredErrors(id: string, errors: PlaygroundError[]): void
).run(JSON.stringify(errors), id)
}

export function recordErrorReport(id: string, report: ErrorReportPayload): void {
setErrors(id, report.errors)
setStructuredErrors(id, report.structuredErrors)
resolveErrorReportWaiters(id, report)
}

export function setUniformValues(id: string, values: Record<string, unknown>): void {
db.prepare(
`UPDATE playground_sessions SET uniform_values_json = ?, updated_at = datetime('now') WHERE id = ?`,
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/routes/api/playground/$.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import {
getSession,
updateShader,
setScreenshot,
setErrors,
setStructuredErrors,
recordErrorReport,
hasSSEConnections,
addSSEConnection,
removeSSEConnection,
waitForScreenshot,
waitForBrowserSyncResult,
} from '../../../lib/server/playground-db'
import type {
PlaygroundError,
Expand Down Expand Up @@ -64,7 +63,7 @@ function jsonResponse(data: unknown, status = 200): Response {
// ---------------------------------------------------------------------------

const WEB_URL = process.env.WEB_URL || 'https://shaderbase.com'
const SCREENSHOT_WAIT_MS = 5000
const BROWSER_SYNC_WAIT_MS = 5000

async function handlePlayground(request: Request): Promise<Response> {
const url = new URL(request.url)
Expand Down Expand Up @@ -170,11 +169,14 @@ async function handlePlayground(request: Request): Promise<Response> {

const previewAvailable = true

// Wait for screenshot from the browser when it is connected.
// Wait for browser feedback when it is connected. Successful renders
// should produce both an empty error report and a screenshot. Failed
// renders should produce a non-empty error report.
const browserConnected = hasSSEConnections(sessionId)
let screenshotBase64: string | null = null
if (browserConnected) {
screenshotBase64 = await waitForScreenshot(sessionId, SCREENSHOT_WAIT_MS)
const browserResult = await waitForBrowserSyncResult(sessionId, BROWSER_SYNC_WAIT_MS)
screenshotBase64 = browserResult.screenshotBase64
}

// Re-fetch session to get latest errors
Expand Down Expand Up @@ -210,8 +212,10 @@ async function handlePlayground(request: Request): Promise<Response> {
errors: string[]
structuredErrors?: PlaygroundError[]
}
setErrors(sessionId, body.errors ?? [])
setStructuredErrors(sessionId, body.structuredErrors ?? [])
recordErrorReport(sessionId, {
errors: body.errors ?? [],
structuredErrors: body.structuredErrors ?? [],
})
return jsonResponse({ status: 'ok' })
}

Expand Down