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

@plumvery/rocas

v0.6.0

Published

Roblox Open Cloud Asset Sync

Readme

Roblox Open Cloud Asset Sync

Upload images, sounds, meshes, animations, and videos to Roblox through the Open Cloud Assets API — and get typed Luau or roblox-ts bindings back, automatically.

CI License Node Roblox Open Cloud Output

Quick start · CLI · Configuration · Studio plugin · Generated output · API reference

English | 日本語


Drop files into assets/, run rocas sync, and require them with full type safety:

local images = require(ReplicatedStorage.Shared.images)

imageLabel.Image = images.ui.button --> "rbxassetid://12345678"

No manual asset ID copy-pasting, no stale IDs, no untyped string tables.

Features

  • All asset types — images, sounds, meshes, animations, videos
  • Hash-based change detection — uploads only what actually changed, tracked in lock files
  • Config-aware re-sync — re-uploads when the rocas.toml creator or assetType changes, even if the file is byte-for-byte identical
  • Stable IDs for models — editing a synced Model updates the existing asset in place instead of minting a new ID
  • Fetch back — rocas fetch downloads what the lock files point at, so a repository can carry IDs instead of asset bodies
  • Recursive directory scanning — nested folders become nested generated objects
  • Luau native by default — generates --!strict type-annotated .luau output
  • roblox-ts compatible — opt in to .luau + .d.ts pairs in an Asphalt-like shape
  • Studio plugin — browse, search, preview, and insert synced assets without leaving Studio
  • Local preview mode — browse local assets in Studio without an Open Cloud API key
  • CLI + library — use rocas sync or require("@plumvery/rocas")

Requirements

| | | |---|---| | Node.js | 18 or newer | | Roblox Open Cloud API key | Required for sync, watch, and fetch. Create one in the Creator Dashboard with Assets read/write permissions for your user or group. The read permission is what lets rocas resolve image IDs and download assets with rocas fetch. |

[!NOTE] rocas plugin and rocas manifest --local work entirely offline — no API key needed.

Install

Globally, as a CLI:

npm install -g @plumvery/rocas

Or as a project dev dependency:

npm install --save-dev @plumvery/rocas

[!NOTE] The package is scoped, but the command it installs is plain rocas. The unscoped package name was rejected by the npm registry as too similar to existing packages.

[!NOTE] On npm 12, rocas plugin needs one extra step. npm 12 blocks a dependency's install scripts unless your project approves them, so the lz4 native module that rbxm-parser needs is left unbuilt. Every other command works without it; only writing the Studio plugin as .rbxm does not. Either approve it once,

npm install-scripts approve lz4

or write the plugin as XML instead, which needs no native code: rocas plugin --output rocas-studio-plugin.rbxmx.

Quick start

1. Create rocas.toml in your project root

[creator]
type = "user"
id = 123456789

[[sync]]
name = "images"
path = "assets/images"
output = "src/shared/images"

[[sync]]
name = "sounds"
path = "assets/sounds"
output = "src/shared/sounds"

2. Add your API key to .env

ROCAS_API_KEY="your-open-cloud-api-key"

[!TIP] Make sure .env is listed in your .gitignore. Never commit an Open Cloud API key.

3. Sync

rocas sync

Assets upload, and src/shared/images.luau + src/shared/sounds.luau are generated with the resulting asset IDs.

4. Keep it running while you work

rocas watch

CLI

| Command | Description | |---------|-------------| | rocas sync | Upload changed assets and regenerate bindings | | rocas watch | Watch asset directories and rocas.toml, syncing on change | | rocas fetch | Download the assets listed in the lock files | | rocas plugin | Generate the static Roblox Studio plugin | | rocas plugin --module | Generate the plugin as a ModuleScript to keep in your project | | rocas plugin --loader | Install the one-time loader that requires that module | | rocas manifest | Generate a ReplicatedStorage manifest ModuleScript from lock files | | rocas help | Show help |

Options

| Flag | Applies to | Default | Description | |------|-----------|---------|-------------| | --debounce <ms> | watch | 10000 | Debounce interval before a sync fires | | --group <name> | fetch | every group | Only fetch this [[sync]] group; repeatable | | --out, -o <dir> | fetch | .rocas-cache | Where to write the downloaded assets | | --output, -o <path> | plugin | Studio local Plugins folder | Where to write the plugin | | --module | plugin | src/server/RocasPlugin.luau | Write the plugin as a project ModuleScript instead of a baked file | | --loader | plugin | rocas-loader.rbxm | Write the one-time loader that requires that module | | --output, -o <path> | manifest | src/shared/RocasManifest.luau | Where to write the manifest module | | --local | manifest | — | Build from local files instead of lock files; no upload, no API key |

Environment variables

| Variable | Description | |----------|-------------| | ROCAS_API_KEY | Roblox Open Cloud API key. Set it in .env or export it in your shell. |

Supported formats

| Type | Extensions | Roblox assetType | |------|------------|--------------------| | Image | .png .jpg .jpeg .bmp .tga | Decal | | Audio | .mp3 .ogg .wav .flac | Audio | | Model | .rbxm .rbxmx | Model | | Mesh | .fbx .glb .gltf .obj | Model — needs allowConvertedFormats | | Video | .mp4 .mov | Video |

Asset types are detected from the file extension, and can be overridden per group with assetType.

[!IMPORTANT] Mesh formats are not auto-detected. Roblox converts .fbx, .glb, .gltf, and .obj into a Model on upload and never hands the original file back, so rocas fetch can't restore them. rocas skips them with an explanation unless the group opts in:

[[sync]]
name = "meshes"
path = "assets/meshes"
allowConvertedFormats = true   # without this, .fbx files are skipped

assetType does not unlock them; it only says which type to upload as. The opt-in is its own switch on purpose, so that setting assetType for an unrelated reason can't quietly turn on one-way uploads.

Animations are .rbxm files too, and .rbxm syncs as a Model, so a group of animation exports has to say so:

[[sync]]
name = "animations"
path = "assets/animations"
assetType = "Animation"

[!WARNING] .rbxm and .rbxmx used to default to Animation. A group that has been syncing them without an explicit assetType sees the asset type change as a config change on the next rocas sync, which re-uploads every one of them as a new Model with a new asset ID. Set assetType = "Animation" on the group before syncing to keep the old behavior and the old IDs.

Image IDs

Open Cloud uploads images as Decal assets, and the ID it returns is the decal, not the image inside it. A decal ID works for Decal.Texture, but ImageLabel.Image, ImageButton.Image, ParticleEmitter.Texture, and friends want the image ID.

So after uploading an image, rocas downloads the decal and reads the image ID out of it, storing it as imageId in the lock file. Generated code, asset maps, and the Studio manifest all prefer imageId and fall back to the decal ID when it isn't there.

{
  "ui/button.png": { "assetId": "12345679", "imageId": "12345678", "hash": "…", "config": "…" }
}

This needs an API key with read access to Assets — Roblox has required authentication on asset delivery since April 2025. Images already in an older lock file are backfilled on the next rocas sync without re-uploading.

[!NOTE] Resolution never fails a sync. If the image ID can't be read (moderation still pending, key missing the read permission), rocas warns, keeps the decal ID, and retries on the next sync. After three failures in a row it stops trying for the rest of that group. Set resolveImageIds = false on a group to turn it off entirely.

Configuration

rocas.toml

[creator]
type = "user"       # "user" or "group"
id = 123456789      # Roblox User ID or Group ID

[[sync]]
name = "images"              # Group name, used for images.lock.json
path = "assets/images"       # Directory to scan recursively
output = "src/shared/images" # Generates images.luau by default
# assetType = "Decal"        # Optional: force asset type
# format = "luau"            # "luau" (default) or "roblox-ts"
# stripExtensions = false    # Remove file extensions from generated keys
# resolveImageIds = true     # Resolve decal IDs to image IDs (default true)
# allowConvertedFormats = true # Allow .fbx/.glb/.gltf/.obj, which cannot be fetched back

See rocas.toml.example for a fully commented reference.

format

| Value | Output | Description | |-------|--------|-------------| | "luau" (default) | .luau | --!strict output with type annotations | | "roblox-ts" | .luau + .d.ts | roblox-ts / Asphalt-compatible output |

stripExtensions

When true, file extensions are removed from generated keys:

-- stripExtensions = false (default)
images.ui["button.png"]

-- stripExtensions = true
images.ui.button

Change detection

rocas keeps a <name>.lock.json inside each synced directory (for example assets/images/images.lock.json). An asset is skipped only when both of these match the lock:

  1. the file content hash, and
  2. a fingerprint of the upload-affecting rocas.toml config (the [creator] type/id and the resolved assetType).

[!IMPORTANT] If you point rocas.toml at a different creator — say you change [creator].id from a group to your user — the next rocas sync re-uploads every affected asset under the new creator, even though the files themselves are unchanged.

Updating in place

When only the file content changed — the creator and assetType still match the lock — rocas updates the existing asset instead of creating a new one, so the ID in your generated code stays put and Roblox keeps the old content as a previous version.

This only applies to the Model asset type. Open Cloud does not support content updates for Audio, Decal, Mesh, Video, or Animation, so editing one of those still uploads a new asset with a new ID.

[!NOTE] .rbxm and .rbxmx default to the Model asset type, so editing one updates the existing asset in place. Binary .rbxm models do update in place — measured against the live API on 2026-09-04 — even though Roblox's asset guide still says content updates are limited to .fbx. A group carrying animation exports needs assetType = "Animation", and those still upload a new asset on every edit.

Editing output, format, or stripExtensions only regenerates code; it never forces a re-upload. rocas watch also watches rocas.toml itself, so saving a config change reloads it and triggers a sync.

Lock entries written by older versions of rocas have no config fingerprint. The first sync after upgrading records the current config as the baseline without re-uploading, so an upgrade alone never churns asset IDs.

If you need to force a full re-upload — for example, you changed the creator while still on a pre-fingerprint lock — delete the relevant *.lock.json and run rocas sync.

Fetching assets back

rocas fetch is sync in reverse: it reads the lock files and downloads what they point at.

rocas fetch                     # every group
rocas fetch --group models      # one group
rocas fetch --out .rocas-cache  # somewhere else

Files land as <assetId>.<ext>, alongside a <group>.fetch.json recording what was downloaded. The extension comes from the bytes Roblox returns rather than the original file name, because they don't always agree — an image comes back as the image, a .fbx comes back as the .rbxm Roblox built from it. An asset whose lock assetId and hash are unchanged, and whose file is still on disk, is not downloaded again.

That is what makes it possible to keep asset bodies out of the repository: commit the lock files, gitignore the sources, and run rocas fetch after a clone. Two things bound how far that goes.

[!WARNING] Don't fetch into a synced path. A downloaded file is not byte-for-byte identical to the original, so the next rocas sync reads it as changed. For a Model that costs a pointless upload; for Decal, Audio, and Video it mints a new asset ID and breaks every reference to the old one. The default output directory sits outside every synced path for exactly this reason, and rocas warns when --out points inside one.

[!NOTE] Mesh sources never come back. Uploading .fbx (or .glb, .gltf, .obj) produces a Model; the original file is gone. Only .rbxm / .rbxmx round-trip, so a project that wants the bytes out of the repository should export models as .rbxm — which syncs as a Model with no extra configuration.

For a single asset, fetchAssetContent returns the bytes directly:

const { fetchAssetContent } = require("@plumvery/rocas");

const rbxm = await fetchAssetContent(assetId, { apiKey });
const older = await fetchAssetContent(assetId, { apiKey, version: 3 });

Roblox Studio plugin

Generate the plugin once:

rocas plugin

The plugin is static — you never need to regenerate it when assets change.

[!NOTE] By default rocas writes the generated .rbxm directly into your Roblox Studio local Plugins folder, because Studio does not recognize .luau files there as local plugins. Use --output <path> to write it elsewhere; .lua and .rbxmx output paths are also supported.

Installing the plugin without npm

Artists and designers on the team usually want the browser, not the CLI. Every release carries a double-click installer for them:

| File | Platform | How to run it | | --- | --- | --- | | rocas-plugin-installer-windows.cmd | Windows | Double-click it. Windows says the publisher could not be verified — choose Run. | | rocas-plugin-installer-macos.zip | macOS | Unzip, then right-click → Open → Open on the .command inside. A plain double-click is refused, because macOS blocks downloaded scripts that are not signed by a registered developer. | | rocas-studio-plugin.rbxm | either | The plugin itself, if you would rather drop it into the Plugins folder by hand. |

Both installers carry the .rbxm inside them as base64 and write it to the same place rocas plugin would — %LOCALAPPDATA%\Roblox\Plugins on Windows, ~/Documents/Roblox/Plugins on macOS. They install nothing else, touch no other folder, and never reach the network. Restart Studio afterwards.

The plugin is static, so a copy taken from a release stays correct until the plugin itself changes. Grab the newest release when it does.

Browsing synced assets

Nothing to bake. The plugin reads the binding modules rocas sync generates, which Rojo or Argon already syncs into ReplicatedStorage. The module's variable name gives the asset type (images → Decal, sounds → Audio, animations → Animation, maps → Model) and the nested keys give each asset's path.

Because those modules are generated from your lock files, the browser lists every synced asset — including ones no script references yet, which show as Used in 0. Nothing filters rows by usage.

Only rocas-generated modules are read. Inline rbxassetid:// literals written by hand elsewhere are deliberately ignored: this is a browser for the assets rocas synced, and picking up arbitrary IDs made it something else.

Open the browser to search assets, preview images, inspect asset IDs, and click Insert to place references into the current place. It re-scans automatically when Rojo or Argon syncs a change.

Rows are grouped into a collapsible folder tree built from those nested paths, with a recursive asset count on each folder. Folders start collapsed; typing in the search box force-expands everything so a match is never hidden. Assets sort alphabetically within a folder.

Each row's preview square shows:

| Type | Preview | |---|---| | Image | the image itself | | Model | Roblox's thumbnail (rbxthumb://type=Asset) | | Audio | a play/stop button — click to preview the sound in Studio | | anything else | a colored square with the type initial |

The colored type square sits behind every thumbnail, so an asset whose thumbnail is missing or still loading still reads as its type.

[!NOTE] An asset appears once its group's generated module is synced into ReplicatedStorage. If you point codegen somewhere else, or have not run rocas sync since adding a group, that group will not be listed.

A baked manifest is still supported and takes priority when present — it knows the group, source path, and declared asset type from rocas.toml:

rocas manifest --output src/shared/RocasManifest.luau

Entries in the manifest override discovered assets with the same ID; anything the manifest does not cover stays as scanned.

Keeping the plugin source in your project

Instead of a baked plugin file, write the plugin as a ModuleScript that lives in your repository:

rocas plugin --module

That writes src/server/RocasPlugin.luau (ServerStorage, so the plugin code is never replicated to production clients). Sync it into the place with Rojo or Argon, then install the loader once:

rocas plugin --loader

The loader is a small plugin that finds the RocasPlugin ModuleScript in ServerStorage or ReplicatedStorage and requires it, passing in the toolbar button and dock widget it owns. Editing the module in your project and letting Rojo sync it reloads the window immediately — no re-bake, no Studio restart.

[!NOTE] The loader itself still has to live in the Studio local Plugins folder: the plugin global only exists for a plugin loaded from there. But it is installed once and never needs regenerating, because it contains no rocas logic of its own. Studio's own documented workflow ("Save as Local Plugin" from a ServerStorage script) copies your script into that folder on every change, which is the re-bake this avoids.

It also watches Script, LocalScript, and ModuleScript source changes inside Studio, updating each asset's usage count as matching asset IDs, paths, or file names appear in script source.

Browsing without uploading

rocas manifest --local --output src/shared/RocasManifest.luau

Local manifests scan the asset folders in rocas.toml directly and do not require ROCAS_API_KEY. In Studio, click Import to choose matching files, or Load on a single row.

[!WARNING] Local mode uses File:GetTemporaryId() and rbxtemp:// IDs. These references only work in the current Studio session — they are not shared, and not saved as permanent Roblox assets. Use rocas sync + rocas manifest when you need durable rbxassetid:// IDs for team, shared, or runtime use.

| Asset type | Insert target | |------------|---------------| | Decal / Image | Selected BasePart as a Decal, or StarterGui as an ImageLabel when no part is selected | | Audio | SoundService as a Sound | | Model / Mesh | Workspace through InsertService:LoadAsset | | Animation | ReplicatedStorage/rocas Animations as an Animation | | Video | StarterGui as a VideoFrame |

Generated output

Given this directory structure:

assets/images/
  ui/
    button.png
    icon.png
  fx/
    spark.png

images.luau:

--!strict
-- This file is auto-generated by rocas. Do not edit manually.

type ImagesType = {
	fx: {
		spark: string,
	},
	ui: {
		button: string,
		icon: string,
	},
}

local images: ImagesType = {
	fx = {
		spark = "rbxassetid://12345678",
	},
	ui = {
		button = "rbxassetid://23456789",
		icon = "rbxassetid://34567890",
	},
}

return images

Usage:

local images = require(path.to.images)

imageLabel.Image = images.ui.button

images.luau:

-- This file is auto-generated by rocas. Do not edit manually.
local images = {
	fx = {
		["spark.png"] = "rbxassetid://12345678",
	},
	ui = {
		["button.png"] = "rbxassetid://23456789",
		["icon.png"] = "rbxassetid://34567890",
	},
}

return images

images.d.ts:

// This file is auto-generated by rocas. Do not edit manually.
declare const images: {
	fx: {
		"spark.png": string
	}
	ui: {
		"button.png": string
		"icon.png": string
	}
}

export = images

Usage:

import images from "shared/images";

imageLabel.Image = images.ui["button.png"];

Programmatic usage

const { loadConfig, loadEnv, syncAll } = require("@plumvery/rocas");

loadEnv();
const config = loadConfig();
await syncAll(config, process.env.ROCAS_API_KEY);

Every export — sync, codegen, lock-file, and Studio plugin helpers — is documented in the API reference.

Codegen formats are pluggable. A format is any object with a name and a render function that returns the files to write:

const { registerCodegenFormat, listCodegenFormats } = require("@plumvery/rocas");

registerCodegenFormat({
	name: "json",
	render(lock, varName, { stripExtensions = false } = {}) {
		return [{ extension: ".json", content: JSON.stringify(lock, null, 2) }];
	},
});

listCodegenFormats(); //=> ["luau", "roblox-ts", "json"]

Each returned entry is { extension, content }, and is written next to the group's output path. Once registered, the format can be selected per sync group with format = "json" in rocas.toml.

Development

git clone https://github.com/Plumvery/rocas.git
cd rocas
npm install
npm test

The rbxm-parser dependency pulls in lz4, a native module built with node-gyp. On macOS with Xcode selected as the active developer directory, node-gyp may fail to resolve the SDK headers:

../lib/binding/lz4_binding.cc:1:10: fatal error: 'string.h' file not found

Point it at the SDK explicitly:

export SDKROOT="$(xcrun --show-sdk-path)"
npm install

Add that export to your shell profile to make it stick.

bin/
  rocas.js          CLI entry point
src/
  index.js          Public library surface
  config.js         .env + rocas.toml loading
  sync.js           Sync orchestration, extension → assetType mapping
  upload.js         Open Cloud Assets API client
  asset-map.js      Lock file reading and asset map building
  codegen.js        Code generation
  formats/          Pluggable output formats (luau, roblox-ts)
  studio-plugin.js  Studio plugin and manifest generation
  watch.js          File watching
test/
  test.js           Test suite (node test/test.js)

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md for the development workflow. Release history lives in CHANGELOG.md.

License

MIT © Plumvery