npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bedrock-rbx/state-s3

v0.3.3

Published

S3 state backend plugin for Bedrock

Downloads

908

Readme

@bedrock-rbx/state-s3

S3 state backend for bedrock.

npm version License: MIT CI

Status: 0.1, pre-1.0. The public API is stabilizing; breaking changes may land in minor releases until 1.0.

What it does

Persists bedrock's deployment state in an S3 bucket, one object per environment, so deploying two environments at once never puts them in contention. It also locks: two CI jobs deploying one environment are serialized, and the second waits for the first rather than both creating resources on Roblox. It is an ordinary bedrock plugin: it reaches core only through the published plugin contract, so nothing it does is closed to a third-party backend.

Install

pnpm add @bedrock-rbx/state-s3
# or: npm install @bedrock-rbx/state-s3

Use

Name the package under plugins and point state at a bucket:

import { defineConfig } from "@bedrock-rbx/core/config";

export default defineConfig({
	environments: { production: {} },
	plugins: ["@bedrock-rbx/state-s3"],
	state: { backend: "s3", bucket: "my-bucket", region: "eu-west-2" },
});

Production state now lives at s3://my-bucket/production.json.

Configuration

| Key | Required | What it does | | --------------------- | -------- | ------------------------------------------------------------- | | bucket | yes | Bucket the state objects live in | | region | yes | Region the bucket lives in | | prefix | no | Folder the objects are written under | | endpoint | no | Endpoint to address instead of AWS | | forcePathStyle | no | Address the bucket as a path segment rather than a subdomain | | checksumCalculation | no | whenSupported (default) or whenRequired | | lockTimeoutMs | no | How long to wait for a held environment; 5 minutes by default | | lockLeaseMs | no | How long a hold is leased for; 1 minute by default |

Credentials

Credentials resolve through the standard AWS Node credential chain, so environment variables, a shared profile, an SSO session, and CI role credentials all work with no bedrock-specific configuration.

BEDROCK_S3_ACCESS_KEY_ID and BEDROCK_S3_SECRET_ACCESS_KEY, plus BEDROCK_S3_SESSION_TOKEN for a temporary credential, are read ahead of AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, so a machine whose AWS variables already point at another account can send bedrock somewhere else:

export BEDROCK_S3_ACCESS_KEY_ID="<access-key-id>"
export BEDROCK_S3_SECRET_ACCESS_KEY="<secret-access-key>"

Each set is read as a whole credential. Setting one half of the prefixed pair leaves the AWS_ pair signing on its own, and a prefixed pair takes its session token from BEDROCK_S3_SESSION_TOKEN alone, ignoring any AWS_SESSION_TOKEN the environment holds, so no signing pairs one account's key with another's secret.

Grant s3:GetObject and s3:PutObject on the objects and on the locks beside them, under the prefix if you configured one. s3:DeleteObject is what lets the conditional-write probe take its scratch object away again:

{
	"Effect": "Allow",
	"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
	"Resource": "arn:aws:s3:::my-bucket/*"
}

Migrating from Mantle

bedrock migrate reads a Mantle state file straight out of the bucket it has always lived in, so state that was never on disk needs no downloading first. Install this plugin and name it under plugins first, then run bedrock migrate with no path and pick S3 as the source.

The coordinates asked for are Mantle's own state.remote block. Mantle keys one object <project>.mantle-state.yml, so the key you answer with is the project name, and the object name itself is accepted too:

# mantle.yml
state:
  remote:
    bucket: my-mantle-states
    key: pirate-wars
    region:
      custom:
        name: auto
        endpoint: https://<account>.r2.cloudflarestorage.com

Migrating onto S3 records what that block says, so the bucket is named once rather than answered again:

export default defineConfig({
	environments: { production: {} },
	plugins: ["@bedrock-rbx/state-s3"],
	state: {
		backend: "s3",
		bucket: "my-mantle-states",
		endpoint: "https://<account>.r2.cloudflarestorage.com",
		prefix: "pirate-wars",
		region: "auto",
	},
});

Mantle's key becomes the prefix, so production lands at s3://my-mantle-states/pirate-wars/production.json and two projects that shared one bucket under Mantle stay apart. Migrating onto a bucket you did not fetch from asks for the bucket, the region, and an endpoint you can skip. Credentials resolve exactly as they do for a deploy.

Mantle's config carries nothing about forcePathStyle or checksumCalculation, so a migrated config has neither. Add them if your store needs them; see S3-compatible stores below.

S3-compatible stores

endpoint and forcePathStyle make non-AWS stores reachable, and checksumCalculation: "whenRequired" drops the checksum headers that some of them reject:

export default defineConfig({
	environments: { production: {} },
	plugins: ["@bedrock-rbx/state-s3"],
	state: {
		backend: "s3",
		bucket: "my-bucket",
		checksumCalculation: "whenRequired",
		endpoint: "http://localhost:9000",
		forcePathStyle: true,
		region: "us-east-1",
	},
});

Support for those stores is best effort: only AWS is tested.

Locking

A deploy takes a hold on its environment before it applies anything, and gives it up once state has been written. The hold is a locks/<environment>.json object beside the state objects, created conditionally so exactly one run can hold it. Because locks sit under their own prefix segment, a bucket lifecycle rule can expire abandoned ones without touching state.

A run that finds the environment held waits, retrying with exponential backoff for five minutes by default; lockTimeoutMs changes that bound and 0 refuses immediately. The wait is reported through the progress port while it happens, and giving up names who holds the environment and since when. A credential that cannot read the lock record ends the wait at once, so a missing s3:GetObject is reported as itself rather than as five minutes of contention.

A hold carries a deadline it renews while the deploy runs, so a deploy killed by a cancelled CI job stops blocking every later one. A hold whose lease is still being renewed is never taken over, however long the deploy holding it runs; a hold nothing renews past its deadline is taken over by the next deploy, through the same conditional write a released hold is taken over with. lockLeaseMs sets how long a hold is leased for, one minute by default and one second at the shortest. A lease the backend could not keep is reported through the progress port, and the state write of a run whose hold was taken over is refused rather than allowed to overwrite what the run that took it over recorded.

A hold is given up by writing a tombstone over its own record, never by deleting the lock object: conditional delete is not portable across S3-compatible stores, and one of them ignores the condition and deletes anyway.

The run is recorded as BEDROCK_LOCK_OWNER when that is set, as the URL of the GitHub Actions run when GITHUB_RUN_ID is, and as the local user otherwise.

Proving the store first

Before the first hold of a deploy is taken, the backend proves the store refuses a create of an object it already holds: it writes a scratch object under locks/.probe-<id>.json, writes it again requiring it to be absent, and reads the refusal as the proof. The scratch object is taken away once the store has answered, and the question is asked once per deploy however many holds follow.

A store that takes the second write evaluated no condition, so it would grant every run that asks the same hold. It gets no locking and the deploy stops saying so, rather than running unprotected: exclusion that does not exclude is worse than none, and a deploy the user expected to be held is never quietly downgraded to one that is not. A store that could not be asked at all is refused on those terms too, naming what it answered.

Without s3:DeleteObject the probe still answers, and the scratch objects are left for the lifecycle rule that expires the locks beside them.

Failures

A missing object is a first deploy, not a failure: reading it yields no state. A corrupt object never collapses to empty state, because a deploy that read empty state would re-create every resource it already owns.

Everything else arrives as a typed StateError. A bucket that does not resolve is stateNotFound and a credential the store refused is stateAccessDenied. Anything only this backend can describe - no credential resolving at all, or a refusal it does not recognize - arrives as pluginStateBackend carrying an S3StateErrorDetail payload.

A hold that could not be taken arrives as a StateLockError carrying an S3StateLockErrorDetail payload, which names the lock object, what went wrong, and on a timeout who held the environment and how long the wait ran. A store that failed the conditional-write probe arrives the same way, as conditionalWritesIgnored where the store took a create it should have refused and conditionalWritesUnproven where it could not be asked.

License

MIT