Protected content
Read a published project's index and files with a short-lived read token that your game gets for its signed-in player, refreshed before it expires, retried once when the server refuses it, and paced when the server asks for fewer requests.
module: com.awakekt.awake:project
module: com.awakekt.awake.project:runtime
transports: com.awakekt.awake.project:transport-ktor, com.awakekt.awake.project:transport-browser
Desktop · Android · iOS · Web
IndexedAssetSource reads a published project's files as its index lists them, checks each one
against the index's size and SHA-256, and keeps it in a ContentCache. It sends requests through a
fetch the host supplies, so com.awakekt.awake:project carries no HTTP library. When the content
server wants a read token on every request, use a TokenAuthorizedFetch: your game supplies the
token, and the engine attaches it, refreshes it, retries with it, and waits out a 429 or a 503 with Retry-After.
Wire a token provider
1. Pick a transport. A ContentTransport sends one GET with the headers it is given and
answers the status, body and response headers. Two ready-made ones cover most games, each in its own
optional module, so a game takes only the HTTP stack it uses:
| Host | Module | Transport |
|---|---|---|
Desktop, Android, iOS: a game with a Ktor HttpClient |
com.awakekt.awake.project:transport-ktor |
KtorContentTransport(client, baseUrl) |
| Web (wasmJs), with no HTTP library | com.awakekt.awake.project:transport-browser |
BrowserContentTransport(baseUrl), over fetch() |
| Anything else | none | your own ContentTransport, below |
With Ktor, pass the client your game already has. Its engine (OkHttp or Android, Darwin, CIO or
Java, Js) and its plugins, such as HttpTimeout, are yours; the transport names no engine.
// The client your game already has, with the engine it chose: OkHttp, Darwin, CIO or Js.
val httpClient = HttpClient(engine)
val transport = KtorContentTransport(httpClient, baseUrl = "https://content.example.com/release/")
val contentFetch = TokenAuthorizedFetch(transport) {
val grant = gameBackend.requestReadToken()
ReadToken(grant.token, expiresIn = grant.expiresInSeconds.seconds)
}
val source = IndexedAssetSource.open(fetch = contentFetch, cache = cache)
In the browser, the transport calls fetch() itself:
val transport = BrowserContentTransport(baseUrl = "https://content.example.com/release/")
val contentFetch = TokenAuthorizedFetch(transport) {
val grant = gameBackend.requestReadToken()
ReadToken(grant.token, expiresIn = grant.expiresInSeconds.seconds)
}
val source = IndexedAssetSource.open(fetch = contentFetch, cache = IndexedDbContentCache(namespace = "harbor-town"))
baseUrl is the folder the index was published to, ending in /; the index's relative URLs resolve
against it the way a browser resolves a link. Without it, the Ktor transport sends each URL as it
comes (for a client with its own defaultRequest URL) and the browser resolves it against the page.
To adapt another client, answer every status, 401, 403, 429 and 503 included, instead of throwing: that
is how a refused token is told apart from a missing file. Pass the response headers too, so the Retry-After of a 429 or
503 is honoured.
val baseUrl = "https://content.example.com/release/"
val transport = ContentTransport { request ->
// Resolve the index's relative URLs, send the headers, and answer every status, 401, 429
// and 503 included, with the response headers, so their Retry-After is honoured.
val (status, body, headers) = httpClient.get(baseUrl + request.url, request.headers)
ContentResponse(status, body, headers)
}
2. Supply the token. The provider is a suspend function that answers a ReadToken: the token
and how long it stays valid. Get it however your game does, typically from your own server once
the player has signed in. The engine calls it on the first request and again before the token
expires, never twice at once.
val contentFetch = TokenAuthorizedFetch(transport) {
// The game's own server signs its player in and answers a short-lived read token.
val grant = gameBackend.requestReadToken()
ReadToken(grant.token, expiresIn = grant.expiresInSeconds.seconds)
}
3. Open the project through it. Passing the TokenAuthorizedFetch to IndexedAssetSource.open
reads the index with the token too, then every file and content bundle.
val source = IndexedAssetSource.open(fetch = contentFetch, cache = cache)
source.prefetch(listOf("assets/crate.glb")) { done, total -> println("Downloaded $done of $total bytes") }
val crate = source.read(AssetPath("assets/crate.glb")).getOrThrow()
With an index you already have, pass the fetch function instead:
IndexedAssetSource(index, contentFetch::fetch, cache). Bundles go through the same fetch: a whole
bundle with fetch, and one member alone with fetchRange (open(..., rangeReads = true)), which
adds a Range header beside the token.
A host that can't send headers
Where a request can carry only a URL, put the token in the query instead. The transport then
ignores request.headers, and request.url arrives with ?access_token=<token> added (after any
query the URL already has, and before any fragment).
val contentFetch = TokenAuthorizedFetch(
transport = { request ->
// This host loads through a URL alone, so the token comes in request.url.
val (status, body) = httpClient.get("https://content.example.com/release/" + request.url, emptyMap())
ContentResponse(status, body)
},
placement = ReadTokenPlacement.QueryParameter(), // ?access_token=...
) {
val grant = gameBackend.requestReadToken()
ReadToken(grant.token, expiresIn = grant.expiresInSeconds.seconds)
}
| Host | Placement |
|---|---|
| Desktop, Android, iOS: any HTTP client that sets request headers | AuthorizationHeader, the default |
Browser fetch() |
AuthorizationHeader; cross-origin, see the browser and CORS |
| A loader that takes only a URL | QueryParameter() |
A token in the URL can be recorded
Servers, proxies, CDNs and browser history may log full URLs, query included. Use
QueryParameter only where the host can't send a header, and keep the token short-lived.
The browser and CORS
When the content server is on another origin than the page, its CORS answers decide what reaches
the game. A browser reports a request it refused for CORS exactly as it does a network failure, so
BrowserContentTransport's error message lists what the server must allow.
| Response header | Value | Why |
|---|---|---|
Access-Control-Allow-Origin |
the game's origin | Without it, every request fails before a status arrives. |
Access-Control-Allow-Headers |
Authorization, Range |
Authorization makes the browser send a preflight OPTIONS first; Range is for a bundle member read alone. |
Access-Control-Expose-Headers |
Retry-After |
Otherwise the browser hides it: a 429 backs off exponentially instead, and a 503 fails at once. |
Access-Control-Max-Age |
e.g. 600 |
Lets the browser reuse a preflight answer instead of sending one per file. |
No cookies are sent cross-origin (credentials: same-origin), so the read token is the only
credential. With QueryParameter there is no preflight, at the cost of the token in the URL.
Rate limits
A server that answers 429 Too Many Requests, or 503 Service Unavailable with a Retry-After,
is waited out and the request sent again, as RateLimitBackoff says (RFC 9110 lets a 503 say, with
Retry-After, how long the service stays unavailable):
Retry-Afterfirst. The wait is exactly what the header says: a number of seconds, or an HTTP date (Wed, 21 Oct 2026 07:28:00 GMT, or either obsolete form). A date already past sends again at once.- Otherwise exponential, with jitter. A 429 with no
Retry-After, or one that doesn't parse, waits up toinitialDelaybefore the first retry, each later one up to twice the one before, never more thanmaxDelay. Each wait is drawn from between half and all of that, so players refused together don't all come back together. - Bounded. A request is sent at most
maxAttemptstimes while it is answered 429 or 503 withRetry-After, and one fetch waits at mostmaxTotalWaitin all. A wait that would go past that isn't started: the fetch fails at once with aContentFetchExceptionwhosestatusis 429 or 503 and whose message says why it stopped. - A 503 only with
Retry-After. A 503 without the header, or with one that doesn't parse, says nothing about when to come back and is not retried: the fetch fails at once with aContentFetchExceptionwhosestatusis 503. There is no exponential fallback for a 503. - Only 429 and 503, only reads. No other status is retried. Every content request is a
GET, so sending it again changes nothing on the server. - Never blocking. A wait suspends, so it is safe on the frame thread, and cancelling the fetch cancels it. After a wait, the token is checked again and refreshed if the wait brought it close to expiring.
Tune it, or turn it off with RateLimitBackoff.None:
val contentFetch = TokenAuthorizedFetch(
transport = KtorContentTransport(HttpClient(engine), "https://content.example.com/release/"),
// At most 3 sends of a request answered 429, and 10 seconds of waiting per fetch.
rateLimitBackoff = RateLimitBackoff(maxAttempts = 3, maxTotalWait = 10.seconds),
) {
val grant = gameBackend.requestReadToken()
ReadToken(grant.token, expiresIn = grant.expiresInSeconds.seconds)
}
| Property | Type | Default | What it does |
|---|---|---|---|
maxAttempts |
Int |
4 | Sends of one request while it is answered 429 or 503 with Retry-After, the first included. 1 never retries. |
maxTotalWait |
Duration |
30 seconds | The longest one fetch waits in all. |
initialDelay |
Duration |
1 second | The longest first wait when the server sends no Retry-After. |
maxDelay |
Duration |
8 seconds | The longest any one wait grows to when the server sends no Retry-After. |
clock |
Clock |
Clock.System |
The wall clock an HTTP-date Retry-After is measured against. |
random |
Random |
Random.Default |
The jitter's source. |
delay |
suspend (Duration) -> Unit |
kotlinx.coroutines.delay |
Takes a wait; a test passes its own to record waits instead. |
Don't retry a 429 twice
If your Ktor client installs HttpRequestRetry, keep 429 out of what it retries, or each wait
is taken twice. Network failures and timeouts stay the client's to retry.
Properties
| Property | Type | Default | What it does |
|---|---|---|---|
transport |
ContentTransport |
required | Sends each request; resolves relative URLs against wherever the index came from. |
placement |
ReadTokenPlacement |
AuthorizationHeader |
Authorization: Bearer <token>, or QueryParameter(name), by default access_token. |
refreshAhead |
Duration |
60 seconds | How long before expiry the next token is asked for. A token shorter-lived than twice this is replaced at half its lifetime. |
timeSource |
TimeSource |
TimeSource.Monotonic |
Measures a token's lifetime, unaffected by changes to the device clock. |
rateLimitBackoff |
RateLimitBackoff |
RateLimitBackoff() |
How a 429, or a 503 with Retry-After, is waited out; see Rate limits. |
tokenProvider |
suspend () -> ReadToken |
required | Answers a current token. |
ReadToken(value, expiresIn) takes the token and how long it stays valid from now: an OAuth
expires_in, or a JWT's exp less the current time.
How it works
- Refresh before expiry. The token is kept until it is within
refreshAheadof expiring, then the next request asks the provider for a new one before it is sent. - One retry on refusal. A 401 or 403 gets a fresh token and the request is sent once more. A
second refusal throws
ContentFetchExceptionwith that status and a message saying the fresh token was refused too. - A 429 waits, and so does a 503 with
Retry-After. See Rate limits. Any other status but 2xx, a 503 withoutRetry-Afterincluded, throws at once, with no retry. - One refresh for many requests.
prefetchreads up to six files at once. When they need a new token at the same moment, or are all refused, they wait on a single call to the provider and share its token. If that call fails, the requests waiting on it fail with it, and the next request asks again. - The cache never sees the token.
ContentCachestores checked bytes under their SHA-256, so a new token, or a new release that lists the same files, reads them from the cache. Only the index is fetched again. - The token stays out of messages.
ContentFetchExceptionnames the URL as you passed it, before the token was added. A transport failure whose message quotes the token is reported with it replaced by<redacted>, and without that cause attached.ReadTokenandContentRequestprint without it, so logging one is safe.
Keep network retries and resumes in the transport
TokenAuthorizedFetch retries only a refused token, a 429 and a 503 with Retry-After. Network retries, timeouts and
resumed downloads belong to your HTTP client, behind the transport.
Play a protected project after sign-in
A routed app declares its routes before it starts, but a protected project can be read only once the
player has signed in. Give its route the project as a loader with projectRoute, from
com.awakekt.awake.project:runtime: the router runs the loader the first time the route is entered,
off the frame thread, and shows the route's loading scene until the project arrives.
// Completed by the sign-in scene once the player has signed in.
val signedIn = CompletableDeferred<PlayerSession>()
val game = app {
scenes {
route("sign-in", label = "Sign in") {
cameraEntity("camera") // and the game's sign-in screen
}
projectRoute(
"harbor-town",
label = "Harbor Town",
loading = {
ui { Text("Loading Harbor Town...") }
update { _, _ ->
// Back to sign-in when the content can't be read; entering again loads again.
val router = requireService(SceneRouterRuntime::class)
if (router.activeRouteStatus is SceneRouteStatus.Failed) router.switchTo("sign-in")
}
},
) {
// Runs off the frame thread the first time the route is entered, and waits for sign-in.
val session = signedIn.await()
val contentFetch = TokenAuthorizedFetch(transport) { session.readToken() }
loadProject(IndexedAssetSource.open(fetch = contentFetch, cache = contentCache))
}
}
}
val router = game.requireService<SceneRouterRuntime>()
When sign-in succeeds, complete what the loader waits for and enter the route:
// The sign-in scene, once the game's server has signed the player in:
signedIn.complete(session)
router.switchTo("harbor-town")
Entering the route before the player has signed in is fine too: the loading scene plays until the
loader returns. To start reading the project before the player enters the route, call
router.preload("harbor-town") as sign-in succeeds; entering it once it has loaded plays it at once.
| When | What happens |
|---|---|
| The route is entered before the project has loaded | activeSceneId names the route, its loading scene plays, and routeStatus is Loading. The project's scene takes its place at the start of the first frame after the loader returns. |
| The route is entered after it has loaded | The project plays at once, with no loading frame. |
| The loader throws, for instance when the token is refused | The route stays on its loading scene with routeStatus Failed(cause), and the failure is logged. Entering the route again with switchTo runs the loader again. |
| The player leaves while it loads | The load goes on, and the project waits, unplayed, for the next entry; the router never switches to the route on its own. Stopping the app cancels the load and closes a project that loaded but never played. |
| The player leaves the route after it played | Its scene stops and the project is closed, physics world included, because a project plays once. Entering again runs the loader again; the files it read come from the ContentCache. |
A route whose scene is not a project uses deferredRoute(id) { ... }, from awake:scene:authoring,
which returns a sceneApp { } instead. It keeps what it loaded for later entries unless it is given
keepLoaded = false. Routes declared with route load before the app starts, as they always have.