{"id":"dreamwire-studio/snack","name":"snack","scope":"dreamwire-studio","platform":"roblox","description":"Pack any Luau value into the smallest practical pile of bytes, and bite it back out.","version":"0.3.0","latest":"0.3.0","versions":["0.1.0","0.2.0","0.2.1","0.3.0"],"license":"GPL-3.0","licenseRating":"unsafe","licenseCaveats":["Strong copyleft: shipping this in your game plausibly requires releasing your game's entire source under GPL-3.0. Not recommended for closed-source projects."],"licenseVerified":false,"dependencies":{},"integrity":"dc4174815db5f227fe4b64b289f4c7645c1252f350cb1d53ae8c5e736fd58d2a","likes":0,"downloads":0,"install":"forest install dreamwire-studio/snack","url":"https://forest.dev/p/roblox/dreamwire-studio/snack","files":"https://api.forest.dev/ai/package/roblox/dreamwire-studio/snack/files","readme":"# snack\n\n[![CI](https://github.com/dreamwire-studio/snacks/actions/workflows/ci.yml/badge.svg)](https://github.com/dreamwire-studio/snacks/actions/workflows/ci.yml)\n[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)\n\nPack any Luau value into the smallest practical pile of bytes — then `bite` it back out.\n\n`snack` converts `nil`, `boolean`, `number`, `string`, `vector`, `buffer`, and `table`\nvalues into compact binary [`buffer`](https://luau.org/library#buffer-library)s. Buffers are\nthe densest way to hold data at rest in the Roblox engine: a packed snack is raw bytes with\nno per-field table overhead, goes straight into DataStores (which accept buffers directly),\nand squeezes far more state under the ~900-byte unreliable remote budget.\n\n```lua\nlocal snack = require(game.ReplicatedStorage.Packages.Snack)\n\nlocal apple: snack.String = snack(\"string\")\nlocal bannana: snack.Number = snack(0)\nlocal carrot: snack.Boolean = snack(false)\n\nprint(snack.bite(apple)) --> \"string\"\nprint(snack.bite(bannana)) --> 0\nprint(snack.bite(carrot)) --> false\n\nlocal pack_lunch = snack.pack(apple, bannana, carrot)\n\nprint(snack.nibble(pack_lunch, 1)) --> \"string\"\nprint(snack.nibble(pack_lunch, 2)) --> 0\nprint(snack.nibble(pack_lunch, 3)) --> false\n\nlocal values, n = snack.unpack(pack_lunch)\nfor index = 1, n do\n\tprint(values[index]) --> \"string\", 0, false\nend\n\nprint(snack.digest(pack_lunch)) \t\t\t\t\t--> \"0706737472696e67030001\" (default hex)\nprint(snack.digest(pack_lunch, \"base64\")) \t\t\t--> \"BwZzdHJpbmcDAAE=\"\nprint(snack.digest(pack_lunch, \"base64-urlsafe\")) \t--> \"BwZzdHJpbmcDAAE\"\n\nlocal restored = snack.undigest(\"0706737472696e67030001\")\nprint(snack.nibble(restored, 1)) --> \"string\"\n```\n\nA `Snack<T>` remembers what went in, so `bite` hands the original type straight back to the\ntype checker — no casts at the call site.\n\n---\n\n## Installation\n\nAdd snack to your `wally.toml` and install:\n\n```toml\n[dependencies]\nSnack = \"dreamwire-studio/snack@0.3.0\"\n```\n\n```sh\nwally install\n```\n\nThen require it through your Rojo project as usual:\n\n```lua\nlocal snack = require(game.ReplicatedStorage.Packages.Snack)\n```\n\nNo dependencies, one ModuleScript, works on the server, the client, and in plain Luau\nruntimes (tested against both [Lute](https://github.com/luau-lang/lute) and the\n[`luau` CLI](https://github.com/luau-lang/luau)).\n\n---\n\n## Quick tour\n\n```lua\nlocal snack = require(game.ReplicatedStorage.Packages.Snack)\nlocal bite = snack.bite\n\n-- Pack anything supported, including nested tables of mixed values:\nlocal save: snack.Table = snack({\n\tcoins = 12500,\n\tlevel = 42,\n\tposition = vector.create(12.5, 4, -100),\n\tinventory = { \"sword\", \"shield\", \"potion\" },\n})\n\nprint(snack.size(save)) --> 82   (the JSON equivalent is ~92 bytes and cannot hold a vector)\n\n-- Unpack it later:\nlocal data = bite(save)\nprint(data.coins) --> 12500\n\n-- Ship it somewhere that wants a plain buffer, then re-brand it on the way back:\nsomeDataStore:SetAsync(key, snack.raw(save))\n\nlocal stored = someDataStore:GetAsync(key)\nif snack.is(stored) then\n\tlocal restored = bite(snack.wrap(stored) :: snack.Table)\nend\n```\n\n---\n\n## API\n\n### `snack(value) → Snack<T>`\n\nThe module itself is callable. Packs `value` into a fresh, exactly-sized buffer and returns\nit. Accepts `nil`, `boolean`, `number`, `string`, `vector`, `buffer`, and `table` (nested\narbitrarily). Anything else — functions, threads, userdata such as `Instance` or `CFrame` —\nraises `snack: cannot serialize a value of type \"...\"`. Cyclic tables raise\n`snack: cannot serialize a cyclic table`.\n\n### `snack.bite(s: Snack<T>) → T`\n\nUnpacks a snack back into the value that was packed. Bind it locally for the short spelling:\n\n```lua\nlocal bite = snack.bite\nprint(bite(a))\n```\n\n`bite` fully validates its input: a truncated, corrupt, or forged buffer raises a\ndescriptive `snack: malformed snack (...)` error rather than a raw buffer access\nerror, and forged length fields cannot cause large allocations or long loops. Wrap the\ncall in `pcall` (or pre-check with `snack.is`) when the bytes come from an untrusted peer.\n\n### `snack.size(s: Snack<any>) → number`\n\nByte length of the packed snack. Sugar for `buffer.len(snack.raw(s))`.\n\n### `snack.raw(s: Snack<any>) → buffer`\n\nThe snack's underlying `buffer` — the same object, not a copy. A snack already *is* a\nbuffer at runtime; `raw` just tells the type checker so, for handing to APIs typed against\n`buffer` (DataStores, remotes, `buffer.*` functions).\n\n### `snack.wrap(bytes: buffer) → Snack<T>`\n\nThe inverse of `raw`: re-brands a plain buffer (for example, one loaded back from a\nDataStore) as a snack, without copying or validating. Annotate or cast the result to tell\nthe type checker what you expect back:\n\n```lua\nlocal restored = snack.wrap(stored) :: snack.String\n```\n\n`wrap` trusts you; pair it with `snack.is` when the bytes might not be a snack.\n\n### `snack.is(value: any) → boolean`\n\n`true` only if `value` is a buffer containing exactly one complete, well-formed snack.\n(A multi-entry pack is deliberately *not* a valid snack — validate packs with\n`pcall(snack.count, p)` instead.)\n\n### `snack.pack(...: Snack | Pack) → Pack`\n\nConcatenates any number of snacks and/or packs into one buffer — a *pack*. Because the\nwire format is self-delimiting, entries are laid end to end with **zero framing bytes**:\na pack's size is exactly the sum of its pieces, and nothing is re-encoded (it is pure\n`buffer.copy`). That makes `pack` three operations in one:\n\n```lua\nlocal p = snack.pack(a, b)        -- build\nlocal p2 = snack.pack(p, c)       -- append\nlocal all = snack.pack(p, p2)     -- merge\nlocal empty = snack.pack()        -- the empty pack (0 bytes)\n```\n\n### `snack.unpack(p: Pack) → ({any}, number)`\n\nDecodes every entry, returning the values as an array **plus the entry count**. Use the\ncount rather than `#values`, because entries can legitimately be `nil`:\n\n```lua\nlocal values, n = snack.unpack(p)\nfor index = 1, n do\n\thandle(values[index])\nend\n```\n\n### `snack.nibble(p: Pack, index: number) → any`\n\nDecodes *only* the entry at `index` (1-based). Other entries are skipped structurally —\ntheir boundaries are walked, but no tables, strings, or buffers are materialized — so\nnibbling one entry out of a large pack is much cheaper than unpacking everything. Errors\nif `index` is past the last entry.\n\n### `snack.count(p: Pack) → number`\n\nNumber of entries, found by walking boundaries without decoding. Errors on malformed\nbytes, so `pcall(snack.count, p)` doubles as pack validation.\n\n### `snack.digest(s: Snack | Pack, algorithm?) → string`\n\nTranscodes a snack or pack into a printable string for transports that can't carry\nbinary — HTTP JSON bodies, headers, query strings. Three algorithms:\n\n| Algorithm | Output | Size |\n| --- | --- | --- |\n| `\"hex\"` (default) | lowercase hexadecimal | 2 chars/byte |\n| `\"base64\"` | RFC 4648 standard, `=`-padded | ~1.33 chars/byte |\n| `\"base64-urlsafe\"` | RFC 4648 url-safe (`-`/`_`), unpadded | ~1.33 chars/byte |\n\nDigesting is **pure transcoding** — no header, no framing — so the digest is exactly the\nbuffer's bytes in printable form, and `undigest` restores them byte-identically.\n\n### `snack.undigest(text: string, algorithm?) → Undigested`\n\nReverses `digest`: pass the same algorithm that produced the text (default `\"hex\"`).\nBecause the round trip is byte-exact, whatever went in comes back out — a digested snack\nis still a snack, a digested pack is still a pack — and the returned `Undigested` type is\nboth a `Snack<any>` *and* a `Pack`, so `bite` and the pack functions all accept it\ndirectly:\n\n```lua\nlocal greeting = snack.bite(snack.undigest(text))          -- if you digested a snack\nlocal entries = snack.count(snack.undigest(text, \"base64\")) -- if you digested a pack\n```\n\nMalformed text errors with a positioned message (`invalid character at position N`, `odd\nhex length`, `misplaced padding`, `truncated base64`, `non-zero trailing bits`); decoding\nis strict so anything that decodes re-digests canonically. The *decoded bytes* are not\nvalidated — pair with `snack.is` or `pcall(snack.count, ...)` when the text comes from an\nuntrusted peer.\n\n### Exported types\n\n| Type | Meaning |\n| --- | --- |\n| `snack.Snack<T>` | A packed value that unpacks to `T` |\n| `snack.Any` | `Snack<any>` |\n| `snack.Nil` | `Snack<nil>` |\n| `snack.Boolean` | `Snack<boolean>` |\n| `snack.Number` | `Snack<number>` |\n| `snack.String` | `Snack<string>` |\n| `snack.Table<T = any>` | `Snack<T>` — optionally precise: `snack.Table<{ number }>` |\n| `snack.Vector` | `Snack<vector>` |\n| `snack.Buffer` | `Snack<buffer>` |\n| `snack.Pack` | Zero or more snacks in one buffer — deliberately not assignable to/from `Snack<T>`, so a multi-entry pack can't be passed to `bite` by accident |\n| `snack.DigestAlgorithm` | `\"hex\" \\| \"base64\" \\| \"base64-urlsafe\"` |\n| `snack.Undigested` | What `undigest` returns: `Snack<any> & Pack`, usable as either |\n\n> **Why `snack.String` and not `snack.string`?** Luau reserves the primitive type names:\n> `export type string = ...` fails to compile with `TypeError: Redefinition of type\n> 'string'`, and `nil` is a keyword that cannot appear as a type name at all. Capitalized\n> aliases are the closest legal spelling.\n\n> **Phantom types.** At runtime every snack is a plain `buffer` — `typeof(a)` is\n> `\"buffer\"`. The `Snack<T>` table type never exists at runtime; it exists only so the\n> type checker can carry `T` from `snack(...)` to `bite(...)`. Don't index a snack; use\n> `snack.raw` when you need the honest runtime type.\n\n---\n\n## What it costs on the wire\n\nEvery value is one tag byte plus a payload. Integers and lengths use unsigned LEB128\nvarints (7 bits per byte), and numbers automatically take the smallest lossless form.\nMeasured sizes:\n\n| Value | Bytes |\n| --- | --- |\n| `nil` | 1 |\n| `true` / `false` | 1 |\n| integers 0–127 (and −1…−127) | 2 |\n| integers to ±16383 | 3 |\n| integers to ±2^53 | 4–9 |\n| `0.5` (fits f32 exactly) | 5 |\n| `1/3` (needs full f64) | 9 |\n| `\"\"` | 2 |\n| `\"hello\"` | 7 (1 tag + 1 length + 5 bytes) |\n| string of *n* bytes | 1 + varint(*n*) + *n* |\n| `vector.create(1, 2, 3)` | 13 (three f32s) |\n| buffer of *n* bytes | 1 + varint(*n*) + *n* |\n| `{}` | 3 |\n| `{ 1, 2, 3 }` | 9 |\n| `{ coins = 250 }` | 13 |\n\n### Format specification\n\n| Tag | Name | Payload |\n| --- | --- | --- |\n| 0 | NIL | — |\n| 1 | FALSE | — |\n| 2 | TRUE | — |\n| 3 | PINT | varint of the integer |\n| 4 | NINT | varint of the negated integer |\n| 5 | F32 | 4-byte IEEE 754 float |\n| 6 | F64 | 8-byte IEEE 754 float |\n| 7 | STRING | varint byte length, then the bytes |\n| 8 | VECTOR | x, y, z as three f32s |\n| 9 | BUFFER | varint byte length, then the bytes |\n| 10 | TABLE | varint array count + that many values, then varint pair count + that many key/value pairs |\n| 11–255 | — | reserved; `bite` rejects them |\n\nNumber packing picks the first lossless form: integers with magnitude ≤ 2^53 become\nPINT/NINT varints; anything else becomes F32 when the f32 round trip is bit-exact and F64\notherwise. `-0` is kept on the float path so its sign survives, `NaN` stays `NaN`, and the\ninfinities fit in an F32. Every number `bite`s back `==`-equal to what went in.\n\nTables encode their array part (`1..rawlen(t)`, holes included as NIL) followed by all\nremaining key/value pairs. Keys may be any supported type. Vectors store the three f32\ncomponents natively backing them (Roblox `Vector3` *is* the native f32 vector type), so\nvector round trips are lossless too.\n\nA *pack* is simply zero or more encoded values laid end to end — the self-delimiting\nformat needs no count header or separators, so packing adds no bytes at all and merging\ntwo packs is plain concatenation.\n\nThe format is stable within a major version: bytes packed by any `0.x` release will be\nbitten correctly by any later `0.x` release.\n\n---\n\n## Guarantees and caveats\n\n- **Lossless round trips** for every supported value: all numbers (including `-0`, `NaN`,\n  ±infinity, and integers to ±2^53), 8-bit-clean strings, vectors, buffer contents, and\n  arbitrarily nested tables.\n- **Tables are read raw.** Encoding uses `rawlen`/`rawget`/`pairs`, so metamethods are\n  never consulted, and metatables are **not** stored or restored — a snack captures a\n  table's own contents only. Decoded tables are always plain tables.\n- **Cycles are rejected** with a clear error. Shared (non-cyclic) references are legal but\n  are duplicated in the output, so `bite` returns independent copies.\n- **Dictionary byte layout follows `pairs` order**, which Luau does not specify. Decoding\n  is always correct, but don't treat the encoded bytes of hash-part tables as a canonical\n  fingerprint across VM versions.\n- **Deep nesting** is recursive; the suite exercises 100 levels. Pathologically deep\n  structures (thousands of levels) can hit the VM's stack limit, which surfaces as a\n  catchable error.\n- **Unsupported types** (`function`, `thread`, and userdata such as `Instance`, `CFrame`,\n  `Color3`) raise immediately, even when nested inside tables. Decompose rich userdata\n  into tables of numbers before packing.\n- **Untrusted bytes are safe to bite**: every read is bounds-checked and forged length\n  fields are caught before they can drive allocations or loops, so malformed input always\n  raises `malformed snack` — guard with `pcall` or `snack.is`.\n\n---\n\n## Recipes\n\n### DataStores\n\nDataStores accept buffers directly, and store them more compactly than JSON-encoded\ntables:\n\n```lua\nlocal store = DataStoreService:GetDataStore(\"PlayerSaves\")\n\n-- Save\nstore:SetAsync(tostring(player.UserId), snack.raw(snack(playerData)))\n\n-- Load\nlocal stored = store:GetAsync(tostring(player.UserId))\nif snack.is(stored) then\n\tlocal playerData = snack.bite(snack.wrap(stored) :: snack.Table)\nend\n```\n\n### Remotes\n\nBuffers replicate efficiently through remotes, which matters most for unreliable remotes\nand their ~900-byte payload budget:\n\n```lua\n-- Sender\nremote:FireServer(snack.raw(snack({ action = \"jump\", direction = vector.create(0, 1, 0) })))\n\n-- Receiver: remote payloads are attacker-controlled, so validate before trusting\nremote.OnServerEvent:Connect(function(player, payload)\n\tif typeof(payload) == \"buffer\" and snack.is(payload) then\n\t\tlocal message = snack.bite(snack.wrap(payload) :: snack.Table)\n\tend\nend)\n```\n\n### Batching messages with packs\n\nAccumulate small messages during a frame and flush them as one payload — one remote\ncall, one buffer, no per-message overhead:\n\n```lua\n-- Sender: queue snacks cheaply, combine on flush\nlocal queue = {}\n\nlocal function send(message)\n\ttable.insert(queue, snack(message))\nend\n\nRunService.Heartbeat:Connect(function()\n\tif #queue > 0 then\n\t\tremote:FireServer(snack.raw(snack.pack(table.unpack(queue)) :: any))\n\t\ttable.clear(queue)\n\tend\nend)\n\n-- Receiver: validate, then take the pack apart\nremote.OnServerEvent:Connect(function(player, payload)\n\tif typeof(payload) ~= \"buffer\" then\n\t\treturn\n\tend\n\tlocal bundle = payload :: any\n\tif not pcall(snack.count, bundle) then\n\t\treturn -- malformed or forged\n\tend\n\tlocal messages, n = snack.unpack(bundle)\n\tfor index = 1, n do\n\t\thandleMessage(player, messages[index])\n\tend\nend)\n```\n\n### Shipping snacks over HTTP\n\n`HttpService` bodies and headers are strings, so digest at the boundary — hex or base64\nin a JSON body, url-safe in a query string:\n\n```lua\n-- Send: a pack of events as base64 inside a JSON body\nHttpService:PostAsync(\n\tendpoint,\n\tHttpService:JSONEncode({\n\t\tevents = snack.digest(eventPack, \"base64\"),\n\t})\n)\n\n-- Or in a query string, where \"=\" and \"/\" would need escaping:\nlocal url = `{endpoint}?state={snack.digest(save, \"base64-urlsafe\")}`\n\n-- Receive (e.g. a webhook response): undigest, validate, then use\nlocal body = HttpService:JSONDecode(response)\nlocal restored = snack.undigest(body.events, \"base64\")\nif pcall(snack.count, restored) then\n\tlocal values, n = snack.unpack(restored)\nend\n```\n\n### Keeping many values packed in memory\n\nA snack is just bytes, so long-lived state you rarely touch (undo history, chunk data,\nreplay frames) can sit pack","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/dreamwire-studio/roblox/snack/0.3.0/readme"}