[studio] Complete editor uploads and queued cloud builds (#135)

* [studio] Complete editor uploads and queued cloud builds

* [studio] Protect unpublished source and remove unused notification RPC
This commit is contained in:
dukedotdev
2026-10-19 11:04:58 -04:00
committed by GitHub
parent 81c372135c
commit a3291bc9ce
30 changed files with 2553 additions and 142 deletions
+528
View File
@@ -0,0 +1,528 @@
import { Hono } from 'hono'
import {
canManageRoomById,
getRoomById,
getSubRoom,
getSubRoomSaveById,
publicStudioBundleFilename,
studioBundleR2Key,
} from '@repo/domain'
import {
generateStudioBuildValidationToken,
validateAndGetAccountId,
validateAndGetRoles,
} from '@repo/jwt'
import { parseMultipart } from './multipart'
import type { App } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)`
interface Job {
buildId: string
roomId: number
subRoomId: number
saveId: number
accountId: number
unityAssetId: string
autoPublish: boolean
templateVersion: number
targets: number[]
manifest: {
Version: number
MainSceneProjectRelativePath: string
ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }>
}
room: unknown
exports: Array<{ e: string; u: string }>
}
interface Lease {
payload: string
lease_token: string
lease_until: string
}
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID()
interface Artifact {
field: string
unityAssetId: string
target: number
kind: string
version: number
}
function isArtifact(value: unknown): value is Artifact {
if (typeof value !== 'object' || value === null) return false
const entry = value as Record<string, unknown>
return (
typeof entry.field === 'string' &&
typeof entry.unityAssetId === 'string' &&
typeof entry.target === 'number' &&
typeof entry.kind === 'string' &&
typeof entry.version === 'number'
)
}
function numericId() {
return (
(BigInt(crypto.getRandomValues(new Uint32Array(1))[0] & 0x7fffffff) << 32n) |
BigInt(crypto.getRandomValues(new Uint32Array(1))[0]) |
1n
).toString()
}
interface CloudBuildRecord {
cloud_build_id: string
started_at: string
completed_at: string | null
error: string | null
unity_asset_id: string
created_by_account_id: number
}
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) {
return {
cloudBuildId: row.cloud_build_id,
startedAt: row.started_at,
completedAt: row.completed_at || null,
error: row.error,
unityAssetId: row.unity_asset_id,
createdByAccountId: row.created_by_account_id,
serverDataFileName:
row.completed_at && !row.error
? `studio-builds/${row.cloud_build_id}/server-data.json`
: null,
}
}
async function privateAuth(request: Request, key: string | undefined) {
if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(request.headers.get('authorization') ?? '')
)
return new Uint8Array(wanted).every((byte, i) => byte === new Uint8Array(got)[i])
}
async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE b.room_id=? AND b.sub_room_id=? AND b.sub_room_data_save_id=? AND j.status IN ('queued','building','publishing') ORDER BY b.started_at DESC LIMIT 1"
)
.bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>()
.post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret)
const roles = await validateAndGetRoles(c.req.raw, secret)
if (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
const form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
return c.json(fail('Invalid room, subroom, or save ID.'), 400)
const [roomId, subRoomId, saveId] = ids.map(Number)
if (!(await canManageRoomById(c.env.DB, roomId, accountId)))
return c.json(fail('This account cannot build this room.'), 403)
if (!(await getSubRoom(c.env.DB, roomId, subRoomId)))
return c.json(fail('No such subroom.'), 404)
const pending = await pendingBuild(c.env.DB, roomId, subRoomId, saveId)
if (pending) return c.json(ok(wire(pending)))
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
const source = save?.StudioSourceAsset as { Filename?: string } | undefined
if (!source?.Filename) return c.json(fail('This save has no Studio source project.'), 400)
const object = await c.env.CDN_ASSETS.get(`roomunityasset/${source.Filename}`)
if (!object) return c.json(fail('The uploaded Studio source manifest is missing.'), 400)
if (object.size > 8 * 1024 * 1024) return c.json(fail('Source manifest exceeds limit.'), 413)
let manifest: Job['manifest']
try {
manifest = await object.json<Job['manifest']>()
} catch {
return c.json(fail('Invalid Studio source manifest JSON.'), 400)
}
if (
!manifest ||
manifest.Version !== 3 ||
manifest.ExportPartitionVersion !== 3 ||
typeof manifest.MainSceneProjectRelativePath !== 'string' ||
!manifest.MainSceneProjectRelativePath.startsWith('Assets/') ||
!manifest.MainSceneProjectRelativePath.endsWith('.unity') ||
!Array.isArray(manifest.Files) ||
manifest.Files.length > 100000 ||
manifest.Files.some(
(file) =>
!file ||
typeof file.p !== 'string' ||
!(file.p.startsWith('Assets/') || file.p.startsWith('Packages/')) ||
file.p
.split('/')
.some(
(part) =>
part === '..' ||
part === '.' ||
part === '' ||
part.toLowerCase().endsWith('.bundle')
) ||
file.p.includes('\\') ||
file.p.includes(':') ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(file.p) ||
!/^[a-z0-9]{40}$/.test(file.n)
) ||
!manifest.Files.some((file) => file.p === manifest.MainSceneProjectRelativePath) ||
new Set(manifest.Files.map((file) => file.p.toLowerCase())).size !== manifest.Files.length ||
(manifest.Exports !== undefined &&
(!Array.isArray(manifest.Exports) ||
manifest.Exports.some(
(item) =>
!item ||
typeof item.e !== 'string' ||
!item.e ||
(item.d !== undefined &&
(!Array.isArray(item.d) || item.d.some((id) => typeof id !== 'string')))
) ||
new Set(manifest.Exports.map((item) => item.e)).size !== manifest.Exports.length))
)
return c.json(fail('Invalid Studio source file manifest.'), 400)
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
if (!targets.length || targets.some((target) => target !== 0 && target !== 2))
return c.json(fail('Invalid build target configuration.'), 503)
const requested = textField(form.buildAssetBundleTypes).split(',').filter(Boolean).map(Number)
if (requested.some((target) => !targets.includes(target)))
return c.json(fail('Requested build platform is unavailable.'), 400)
const now = new Date().toISOString()
const job: Job = {
buildId: numericId(),
roomId,
subRoomId,
saveId,
accountId,
unityAssetId: guid(),
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
targets: requested.length ? requested : targets,
manifest,
room: await getRoomById(c.env.DB, roomId),
exports: (manifest.Exports ?? []).map((item) => ({
e: item.e,
u: guid(),
})),
}
// The initial lookup is only a fast path. Check again inside the creation
// transaction so concurrent retries cannot dispatch two builds for one save.
const created = await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO studio_cloud_build (cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
SELECT ?,?,?,?,?,?,?,'',NULL WHERE NOT EXISTS (
SELECT 1 FROM studio_cloud_build b JOIN studio_cloud_build_job j USING(cloud_build_id)
WHERE b.room_id=? AND b.sub_room_id=? AND b.sub_room_data_save_id=? AND j.status IN ('queued','building','publishing'))`
).bind(
job.buildId,
roomId,
subRoomId,
saveId,
job.unityAssetId,
accountId,
now,
roomId,
subRoomId,
saveId
),
c.env.DB.prepare(
'INSERT INTO studio_cloud_build_job (cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
])
if (!created[0].meta.changes) {
const existing = await pendingBuild(c.env.DB, roomId, subRoomId, saveId)
return existing
? c.json(ok(wire(existing)))
: c.json(fail('Build state changed; retry creation.'), 409)
}
return c.json(
ok(
wire({
cloud_build_id: job.buildId,
started_at: now,
completed_at: null,
error: null,
unity_asset_id: job.unityAssetId,
created_by_account_id: accountId,
})
)
)
})
.get('/cloud-builds', async (c) => {
const id = c.req.query('cloudBuildId') ?? ''
if (!ID.test(id)) return c.json(fail('Invalid build ID.'), 400)
const row = await c.env.DB.prepare('SELECT * FROM studio_cloud_build WHERE cloud_build_id = ?')
.bind(id)
.first<CloudBuildRecord>()
return row ? c.json(ok(wire(row))) : c.json(fail('No such build.'), 404)
})
.on(['POST', 'PUT'], '/cloud-builds/cancel', async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const form = await c.req.parseBody()
const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
)
.bind(id)
.first<{ room_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='cancelled',lease_token=NULL WHERE cloud_build_id=? AND status IN ('queued','building')"
).bind(id),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Build cancelled.' WHERE cloud_build_id=? AND completed_at='' AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=studio_cloud_build.cloud_build_id AND status='cancelled')"
).bind(new Date().toISOString(), id),
])
return c.json(ok(null))
})
.use('/internal/cloud-builds/*', async (c, next) => {
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
return next()
})
.post('/internal/cloud-builds/claim', async (c) => {
const now = new Date().toISOString()
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build SET error='Build worker lease expired.',completed_at=? WHERE completed_at='' AND cloud_build_id IN (SELECT cloud_build_id FROM studio_cloud_build_job WHERE lease_until<? AND ((status='building' AND attempts>=3) OR status='publishing'))"
).bind(now, now),
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE lease_until<? AND ((status='building' AND attempts>=3) OR status='publishing')"
).bind(now),
])
const token = guid()
const until = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const lease = await c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='building',lease_token=?,lease_until=?,attempts=attempts+1
WHERE cloud_build_id=(SELECT cloud_build_id FROM studio_cloud_build_job WHERE (status='queued' OR (status='building' AND lease_until<?)) AND attempts<3 ORDER BY created_at LIMIT 1)
RETURNING payload,lease_token,lease_until`
)
.bind(token, until, now)
.first<Lease>()
if (!lease) return c.json(null)
const job = JSON.parse(lease.payload) as Job
return c.json({
job,
leaseToken: token,
leaseUntil: until,
validationAccessToken: await generateStudioBuildValidationToken(
job.buildId,
await c.env.JWT_SECRET.get()
),
})
})
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
)
.bind(
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.run()
return c.json({ active: result.meta.changes > 0 })
})
.post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw)
const id = c.req.param('id')
const lease = await c.env.DB.prepare(
"SELECT payload,lease_token,lease_until FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='building' AND lease_until>?"
)
.bind(id, textField(form.leaseToken), new Date().toISOString())
.first<Lease>()
if (!lease) return c.json(fail('Build lease expired or cancelled.'), 409)
const job = JSON.parse(lease.payload) as Job
const error = typeof form.error === 'string' ? form.error.slice(0, 16000) : null
if (error) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed' WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET error=?,completed_at=? WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='failed')"
).bind(error, new Date().toISOString(), id, id, lease.lease_token),
])
return c.json(ok(null))
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown
try {
entries = JSON.parse(textField(form.artifacts, '[]'))
} catch {
return c.json(fail('Invalid build artifact metadata.'), 400)
}
if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>()
for (const entry of entries) {
const file = form[entry.field]
const key = `${entry.unityAssetId}:${entry.target}:${entry.kind}`
if (
!assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) ||
entry.version !== 5 ||
!(file instanceof File) ||
file.size === 0 ||
seen.has(key)
)
return c.json(fail('Invalid build artifact.'), 400)
seen.add(key)
}
for (const asset of assets)
for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400)
const writes: D1PreparedStatement[] = []
for (const entry of entries) {
const file = form[entry.field] as File
if (new TextDecoder().decode(await file.slice(0, 8).arrayBuffer()) !== 'UnityFS\0')
return c.json(fail('Artifact is not a UnityFS asset bundle.'), 400)
}
const locked = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='publishing' WHERE cloud_build_id=? AND lease_token=? AND status='building' AND lease_until>?"
)
.bind(id, lease.lease_token, new Date().toISOString())
.run()
if (!locked.meta.changes) return c.json(fail('Build lease expired or cancelled.'), 409)
const keys: string[] = []
try {
for (const entry of entries) {
const file = form[entry.field] as File
const bytes = await file.arrayBuffer()
const hash = btoa(
String.fromCharCode(...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)))
)
const baseFilename = publicStudioBundleFilename(
job.roomId,
entry.unityAssetId,
entry.target === 2 ? 'android' : 'windows',
entry.kind
)
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key)
writes.push(
c.env.DB.prepare(
"INSERT INTO unity_asset (unity_asset_id,target,version,kind,filename,hash) SELECT ?,?,?,?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing') ON CONFLICT(unity_asset_id,target,version,kind) DO UPDATE SET filename=excluded.filename,hash=excluded.hash"
).bind(
entry.unityAssetId,
entry.target,
entry.version,
entry.kind,
filename,
hash,
id,
lease.lease_token
)
)
}
const serverDataKey = `roomunityasset/studio-builds/${id}/server-data.json`
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced.
writes.push(
c.env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
}
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(new Date().toISOString(), id, id, lease.lease_token)
)
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
).bind(id, lease.lease_token)
)
// Recheck the lease and target inside the publication transaction. R2
// uploads can outlast a lease, and a room can be deleted while compiling.
const publicationTime = new Date().toISOString()
const results = await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS (
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
c.env.DB.prepare(
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id),
...writes,
])
if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
return c.json(fail('Build lease expired or target save removed.'), 409)
}
} catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building' WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
)
.bind(id, lease.lease_token)
.run()
throw error
}
return c.json(ok(null))
})
export default app
+4
View File
@@ -17,6 +17,10 @@ export type Env = SharedHonoEnv & {
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
CLOUD_BUILD_KEY?: string
CLOUD_BUILD_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string
}
/** Variables can be extended */
+6 -1
View File
@@ -19,6 +19,7 @@ export interface CloudBuildRow {
error: string | null
unityAssetId: string
createdByAccountId: number
serverDataFileName?: string | null
}
export type BundlePlatform = StudioBundlePlatform
@@ -58,10 +59,14 @@ function toCloudBuild(row: BuildListRow): CloudBuildRow {
return {
cloudBuildId: row.cloud_build_id,
startedAt: row.started_at,
completedAt: row.completed_at,
completedAt: row.completed_at || null,
error: row.error,
unityAssetId: row.unity_asset_id,
createdByAccountId: row.created_by_account_id,
serverDataFileName:
row.completed_at && !row.error && /^\d+$/.test(row.cloud_build_id)
? `studio-builds/${row.cloud_build_id}/server-data.json`
: null,
}
}
+10 -8
View File
@@ -10,6 +10,7 @@ import {
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import cloudBuilds from './cloud-builds'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
import { parseMultipart } from './multipart'
@@ -160,10 +161,14 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.route('/', cloudBuilds)
.get('/', async (c) => {
return c.text('hello, world!')
})
// There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
// The editor lists a room's cloud builds with
// GET /cloud-builds/for-room?roomId=&subRoomId=&skip=&take= on this host.
@@ -182,16 +187,13 @@ const app = new Hono<App>()
// Local Windows + Android bundles from the editor button under Upload.
.post('/cloud-builds/from-editor', (c) => postLocalCloudBuild(c))
// Studio asks how often it may request cloud builds. Stubbed: this server
// never queues a real cloud build (every build is local, see from-editor),
// so the numbers are permissive placeholders. The field names are an
// ASSUMPTION — what the editor reads from this response has not been
// observed; adjust once it has.
// CloudBuildRateLimitConfigDTO reads these three fields directly (no Result
// envelope). Missing MaxBuildsPerInterval becomes zero and blocks the first build.
.get('/cloud-builds/get-rate-limit-config', (c) =>
c.json({
maxBuildsPerHour: 100,
maxConcurrentBuilds: 10,
cooldownSeconds: 0,
buildThrottleIntervalHours: 1,
warnAfterBuildsPerInterval: 90,
maxBuildsPerInterval: 100,
})
)
+10 -4
View File
@@ -19,6 +19,12 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
const TEST_SECRET = 'test-signing-key'
it('returns an empty recommendation list when no circuit corpus is installed', async () => {
const response = await SELF.fetch(`${ORIGIN}/commonQuery/rrstudio/chiprecommendations`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
})
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
@@ -113,13 +119,13 @@ it('lists no cloud builds for a room', async () => {
expect(await res.json()).toEqual({ results: [], totalResults: 0 })
})
it('serves a stubbed cloud build rate limit config', async () => {
it('serves the Studio rate-limit DTO without blocking a first build', async () => {
const res = await SELF.fetch('https://example.com/cloud-builds/get-rate-limit-config')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
maxBuildsPerHour: 100,
maxConcurrentBuilds: 10,
cooldownSeconds: 0,
buildThrottleIntervalHours: 1,
warnAfterBuildsPerInterval: 90,
maxBuildsPerInterval: 100,
})
})
@@ -0,0 +1,470 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com'
const key = 'cloud-worker-private-test-key'
const worker = { Authorization: `Bearer ${key}` }
const b64 = (value: string | ArrayBuffer) =>
btoa(typeof value === 'string' ? value : String.fromCharCode(...new Uint8Array(value)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
async function auth(id = 42, studio = true) {
const payload = b64(
JSON.stringify({
sub: String(id),
role: studio ? ['betastudio'] : [],
exp: Date.now() / 1000 + 600,
})
)
const input = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + payload
const secret = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode('test-key'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
return {
Authorization:
'Bearer ' +
input +
'.' +
b64(await crypto.subtle.sign('HMAC', secret, new TextEncoder().encode(input))),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = key
env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [
...ROOM_SCHEMA_DDL,
...SUBROOM_SCHEMA_DDL,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO room (data) VALUES (?)')
.bind(
JSON.stringify({
RoomId: 3000,
Name: 'CloudTest',
CreatorAccountId: 42,
Roles: [],
Tags: [],
Accessibility: 1,
})
)
.run()
await env.DB.prepare(
'INSERT INTO subroom (sub_room_id,room_id,data,current_save_id) VALUES (3001,3000,?,3002)'
)
.bind(JSON.stringify({ Name: 'Home' }))
.run()
await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3002,3001,?)'
)
.bind(JSON.stringify({ StudioSourceAsset: { Filename: 'manifest' }, UnitySubAssets: [] }))
.run()
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
ExportPartitionVersion: 3,
Files: [{ n: 'a'.repeat(40), p: 'Assets/Test.unity' }],
Exports: [],
})
)
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
ExportPartitionVersion: 3,
Files: [{ n: 'a'.repeat(40), p: 'Assets/Test.unity' }],
Exports: [],
})
)
})
async function create(id = 42, studio = true, extra = {}) {
return SELF.fetch(origin + '/cloud-builds/create', {
method: 'POST',
headers: await auth(id, studio),
body: new URLSearchParams({
roomId: '3000',
subRoomId: '3001',
subRoomDataSaveId: '3002',
autoPublish: 'false',
...extra,
}),
})
}
async function claim() {
const response = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
expect(response.status).toBe(200)
return response.json<{
job: {
buildId: string
unityAssetId: string
targets: number[]
exports: Array<{ e: string; u: string }>
}
leaseToken: string
validationAccessToken: string
}>()
}
it('rejects malformed source manifests and preserves compact SDK export identifiers', async () => {
for (const manifest of [
null,
{ Files: [null] },
{
Version: 3,
ExportPartitionVersion: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
Files: [{ p: 'Assets/../Test.unity', n: 'a'.repeat(40) }],
},
]) {
await env.CDN_ASSETS.put('roomunityasset/manifest', JSON.stringify(manifest))
expect((await create()).status).toBe(400)
}
await env.CDN_ASSETS.put(
'roomunityasset/manifest',
JSON.stringify({
Version: 3,
ExportPartitionVersion: 3,
MainSceneProjectRelativePath: 'Assets/Test.unity',
Files: [{ p: 'Assets/Test.unity', n: 'a'.repeat(40) }],
Exports: [{ e: 'object-1', d: [] }],
})
)
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.exports).toEqual([
{ e: 'object-1', u: expect.stringMatching(/^[0-9a-f-]{36}$/) },
])
})
it('requires Studio authorization and room ownership', async () => {
expect((await create(42, false)).status).toBe(403)
expect((await create(99)).status).toBe(403)
expect((await create(42, true, { subRoomDataSaveId: '9999' })).status).toBe(400)
})
it('queues a numeric SDK build ID and returns the exact saved build contract', async () => {
const response = await create()
expect(response.status).toBe(200)
const result = await response.json<{
value: { cloudBuildId: string; completedAt: null; unityAssetId: string }
}>()
expect(result.value.cloudBuildId).toMatch(/^\d+$/)
expect(BigInt(result.value.cloudBuildId)).toBeLessThan(1n << 63n)
expect(result.value.completedAt).toBeNull()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).toBe(result.value.cloudBuildId)
const status = await SELF.fetch(
origin + '/cloud-builds?cloudBuildId=' + result.value.cloudBuildId
)
expect((await status.json<{ value: unknown }>()).value).toEqual(result.value)
const lease = await claim()
expect(lease.job.buildId).toBe(result.value.cloudBuildId)
expect(lease.job.targets).toEqual([0])
const validation = JSON.parse(
atob(lease.validationAccessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
)
expect(validation.aud).toBe('studio-build-validation')
expect(validation.sub).toBeUndefined()
expect(validation.buildId).toBe(lease.job.buildId)
const wrong = await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/heartbeat`, {
method: 'POST',
headers: { ...worker, 'Content-Type': 'application/json' },
body: JSON.stringify({ leaseToken: 'wrong' }),
})
expect(await wrong.json()).toEqual({ active: false })
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('error', 'Real compiler failure')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(200)
const failed = await SELF.fetch(origin + '/cloud-builds?cloudBuildId=' + lease.job.buildId)
expect((await failed.json<{ value: { error: string; completedAt: string } }>()).value.error).toBe(
'Real compiler failure'
)
})
it('reclaims expired jobs with a new lease and rejects the stale worker', async () => {
await create()
const first = await claim()
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until='2000-01-01' WHERE cloud_build_id=?"
)
.bind(first.job.buildId)
.run()
const second = await claim()
expect(second.job.buildId).toBe(first.job.buildId)
expect(second.leaseToken).not.toBe(first.leaseToken)
const form = new FormData()
form.set('leaseToken', first.leaseToken)
form.set('error', 'stale worker')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${first.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(409)
})
it('gates worker dispatch, rejects incomplete artifacts, and stores real-format version 5 bundles', async () => {
expect(
(await SELF.fetch(origin + '/internal/cloud-builds/claim', { method: 'POST' })).status
).toBe(401)
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
await env.DB.prepare(
"INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3003,3001,'{}') ON CONFLICT DO NOTHING"
).run()
await env.DB.prepare('UPDATE subroom SET staged_save_id=3003 WHERE sub_room_id=3001').run()
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('artifacts', '[]')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(400)
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle0',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
form.set('bundle0', new File(['UnityFS\0test-fixture-bundle'], 'bundle.assetbundle'))
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(200)
const row = await env.DB.prepare(
'SELECT version,hash,filename FROM unity_asset WHERE unity_asset_id=?'
)
.bind(lease.job.unityAssetId)
.first<{ version: number; hash: string; filename: string }>()
expect(row?.version).toBe(5)
expect(row?.hash).toBeTruthy()
expect(await (await env.CDN_ASSETS.get('room/' + row!.filename))?.text()).toBe(
'UnityFS\0test-fixture-bundle'
)
const save = await env.DB.prepare(
"SELECT json_extract(data,'$.UnityAssetId') AS asset FROM subroom_save WHERE sub_room_data_save_id=3002"
).first<{ asset: string }>()
expect(save?.asset).toBe(lease.job.unityAssetId)
expect(
await env.DB.prepare(
'SELECT current_save_id,staged_save_id FROM subroom WHERE sub_room_id=3001'
).first()
).toMatchObject({ current_save_id: 3002, staged_save_id: 3003 })
})
it('cancels queued jobs without allowing a late worker to complete them', async () => {
const response = await create()
const { value } = await response.json<{ value: { cloudBuildId: string } }>()
const lease = await claim()
expect(lease.job.buildId).toBe(value.cloudBuildId)
const cancel = await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'POST',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: value.cloudBuildId }),
})
expect(cancel.status).toBe(200)
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('error', 'late failure')
expect(
(
await SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
).status
).toBe(409)
})
async function finish(lease: Awaited<ReturnType<typeof claim>>, artifacts: string) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('artifacts', artifacts)
form.set('bundle', new File(['UnityFS\0test-fixture-bundle'], 'bundle.assetbundle'))
return SELF.fetch(origin + `/internal/cloud-builds/${lease.job.buildId}/finish`, {
method: 'POST',
headers: worker,
body: form,
})
}
function artifacts(lease: Awaited<ReturnType<typeof claim>>) {
return JSON.stringify([
{ field: 'bundle', unityAssetId: lease.job.unityAssetId, target: 0, kind: 'main', version: 5 },
])
}
it('rejects malformed artifact metadata without consuming the lease', async () => {
await create()
const lease = await claim()
for (const value of ['{', 'null', '{}', '[null]', '[42]', '[{"field":false}]'])
expect((await finish(lease, value)).status).toBe(400)
expect((await finish(lease, artifacts(lease))).status).toBe(200)
})
it('publishes a built staged save and marks completion in one transaction', async () => {
await env.DB.prepare(
'UPDATE subroom SET current_save_id=3003,staged_save_id=3002 WHERE sub_room_id=3001'
).run()
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
expect((await finish(lease, artifacts(lease))).status).toBe(200)
expect(
await env.DB.prepare(
'SELECT current_save_id,staged_save_id FROM subroom WHERE sub_room_id=3001'
).first()
).toEqual({ current_save_id: 3002, staged_save_id: null })
})
it('rolls back completion and uploaded objects when room publication fails, allowing retry', async () => {
await env.DB.prepare(
"UPDATE room SET data=json_remove(data,'$.BecameRRStudioRoomAt') WHERE room_id=3000"
).run()
await env.DB.prepare(
"CREATE TRIGGER fail_studio_publication BEFORE UPDATE ON room BEGIN SELECT RAISE(ABORT, 'publication fixture failure'); END"
).run()
await create(42, true, { autoPublish: 'true' })
const lease = await claim()
try {
expect((await finish(lease, artifacts(lease))).status).toBe(500)
expect(
await env.DB.prepare('SELECT completed_at FROM studio_cloud_build WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ completed_at: '' })
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'building' })
expect(
await env.CDN_ASSETS.get(`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`)
).toBeNull()
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
} finally {
await env.DB.prepare('DROP TRIGGER fail_studio_publication').run()
}
expect((await finish(lease, artifacts(lease))).status).toBe(200)
})
it('fails a repeatedly expired job instead of leaving it queued forever', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET attempts=3,lease_until='2000-01-01' WHERE cloud_build_id=?"
)
.bind(lease.job.buildId)
.run()
expect(await claim()).toBeNull()
expect(
await env.DB.prepare(
'SELECT status,lease_token FROM studio_cloud_build_job WHERE cloud_build_id=?'
)
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed', lease_token: null })
const status = await SELF.fetch(origin + '/cloud-builds?cloudBuildId=' + lease.job.buildId)
expect((await status.json<{ value: { error: string } }>()).value.error).toBe(
'Build worker lease expired.'
)
})
it('reuses one queued build when create requests arrive concurrently', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
expect(
await env.DB.prepare('SELECT COUNT(*) AS count FROM studio_cloud_build_job').first()
).toEqual({ count: 1 })
})
it('does not complete a build whose submitted save was deleted during compilation', async () => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002').run()
try {
expect((await finish(lease, artifacts(lease))).status).toBe(409)
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`)
).toBeNull()
} finally {
await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_data_save_id,sub_room_id,data) VALUES (3002,3001,?)'
)
.bind(save!.data)
.run()
}
})