--- title: "The snapshot file format" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{The snapshot file format} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` This is the normative description of the files shinysnap writes and reads. The current format version is 1. Readers accept every earlier version and convert on read; the number is bumped only for incompatible changes. The `app$version` field is for the *app's* migrations (the `migrate` hook of `snap_restore()`); the `format` field is for the package's. ## The snapshot object In R, a snapshot is a plain list of class `shinysnap`: ```{r} library(shinysnap) snap <- snap_unserialize('{ "format": 1, "app": {"name": "myapp", "version": "2.4.1"}, "created": "2026-09-16T18:22:03Z", "inputs": {"n": 100, "rate": 0.025}, "values": {"prefs": {"digits": 3}}, "bindings": {"n": "shiny.sliderInput", "rate": "shiny.numberInput"} }') str(unclass(snap)) ``` | field | content | |---|---| | `format` | the format version, an integer | | `app` | `name` and `version` of the app; either may be `NULL` | | `created` | when the state was captured, ISO 8601 in UTC | | `producer` | versions of shinysnap, shiny, and R that wrote the file | | `inputs` | named list of input values, keyed by fully namespaced id | | `values` | named list of server-side values | | `bindings` | named character vector, input id to client binding name | | `attachments` | file records, present in bundles only | | `meta` | free-form, user-supplied | `inputs` and `values` hold ordinary R values, exactly what `input$x` returns. The encoding below is applied at write and read time only. ## The JSON file A file is one JSON object with the keys above, in that order, pretty-printed with two-space indentation and a trailing newline, in UTF-8. Input ids are written in the order captured, which `snap_take()` sorts, so files of the same state are byte-identical and diffs in version control are meaningful. A container is written on one line when it fits in 80 columns and one element per line otherwise. ### Encoding rules 1. `NULL` is `null`. 2. An atomic vector of length one without names is a JSON scalar; longer vectors are arrays. Since R has no scalar type, a scalar and a one-element array read back to the same value. A vector of length zero is a typed wrapper, `{"$type": "", "value": []}`, because a bare `[]` has no type. 3. Logicals are `true`/`false`, strings are escaped per RFC 8259 with non-ASCII kept as UTF-8, integers are plain digit runs, and doubles are written with the fewest digits that read back to the same value (`zmij::format_double()`). A finite double always contains a `.` or an `e`, so `1` is an integer and `1.0` a double. 4. `NA` inside a vector of length greater than one is `null` at that position; the other elements fix the type. A vector that is entirely `NA`, including a single `NA`, is a typed wrapper with `null` values, so that `NULL`, `NA`, and `NA_character_` stay distinct. 5. Non-finite doubles use the strings `"inf"`, `"-inf"`, and `"NaN"` inside a `double` wrapper, which is used only when such a value is present. 6. Named atomic vectors are `{"$type": "", "names": [...], "value": [...]}`. 7. `Date` is `{"$type": "Date", "value": ["2024-01-01", null]}`; `POSIXct` is `{"$type": "POSIXct", "tz": "UTC", "value": ["2024-01-01T10:00:00.000Z"]}`, written in the stored time zone at millisecond precision (the `tz` key is absent when the value has no time zone attribute, and the offset is `Z` for UTC and for the session time zone, which is formatted in UTC); `difftime` is `{"$type": "difftime", "units": "secs", "value": [...]}`; a factor is `{"$type": "factor", "levels": [...], "value": ["a", null]}` with an `"ordered": true` flag when ordered. 8. A matrix or array is `{"$type": "array", "storage": "double", "dim": [2, 3], "dimnames": [["a", "b"], null], "value": [...]}` with the values in column-major order (`"matrix"` is accepted as an alias on read). 9. A data frame is `{"$type": "data.frame", "nrow": 3, "columns": {"x": ..., "y": ...}}`, with a `"row.names"` array when the row names are not the automatic ones. Tibbles are written as data frames. 10. A list whose names are all present and unique is a JSON object. Any other list (unnamed, partially named, or empty) is `{"$type": "list", "names": [...], "value": [...]}` with `names` omitted when absent, except that an unnamed list with at least one element that is not a scalar is written as a bare array, which reads back as a list. 11. Anything else (environments, functions, S4 and R6 objects, unknown classes) is an error by default. With `unsupported = "rds"` it becomes `{"$type": "rds", "class": [...], "base64": "..."}` (in a bundle, `{"$type": "rds", "class": [...], "path": "objects/1-values_fit.rds"}`). Reading these requires `trust = TRUE`; otherwise they decode to `NULL` with one warning that lists them. 12. Attributes other than the ones above are dropped on write. The format describes values, not R objects. Keys starting with `$` are reserved. ### Examples ```{r} cat(snap_serialize(list(inputs = list( n = 1L, x = 1, sum = 0.1 + 0.2, flag = c(TRUE, NA), empty = character(0), missing = NA, extremes = c(0.5, Inf), named = c(a = 1L, b = 2L), when = as.Date("2024-01-15"), stamp = as.POSIXct("2024-01-01 10:00:00.5", tz = "UTC"), level = factor("b", levels = c("a", "b")), M = matrix(1:6, 2, dimnames = list(c("r1", "r2"), NULL)), df = data.frame(x = 1:2, y = c("a", "b")), mixed = list(1, "a"), nested = list(a = 1, b = list(c = NULL)) )))) ``` ### Reading The reader parses the text with jsonlite (`simplifyVector = FALSE`) and walks the tree. Arrays whose elements are all scalars or `null` become atomic vectors, typed from the non-null elements (integer when every number parsed as an integer, double otherwise); mixing kinds is an error. Objects with a `$type` key go through the typed decoders; other objects become named lists; arrays with a non-scalar element become unnamed lists. A `$type` the reader does not know is an error naming the id, unless `unknown_types = "keep"` keeps the raw value. Duplicate keys are an error. The round-trip contract: for every supported type, writing and reading gives a value `identical()` to the original, and `snap_read(snap_write(x))` is identical to `x` except for `producer`, which the writer always stamps. ```{r} x <- list(inputs = list( sum = 0.1 + 0.2, tiny = 1e-300, dates = as.Date(c("2024-01-01", NA)), M = matrix(c(1.5, NA, Inf, 2), 2), f = factor(c("a", NA), levels = c("a", "b")) )) back <- snap_unserialize(snap_serialize(x)) identical(snap_inputs(back), x$inputs) ``` ## The bundle (`.zip`) A bundle is a zip archive with: - `manifest.json`: exactly the JSON above, with an `attachments` section. Each entry is a file record: `{"$type": "file", "name": "data.csv", "size": 1234, "type": "text/csv", "path": "attachments/upload/data.csv"}`; the fields are arrays when a `fileInput()` held several files. - `attachments//`: the uploaded files. Names are reduced to `A-Z a-z 0-9 . _ -` and disambiguated with a counter when they repeat. - `objects/-.rds`: values written with `unsupported = "rds"`. On read, the archive is checked before extraction: entries with `..` or absolute paths are refused, and the uncompressed total must stay under `getOption("shinysnap.max_bundle_bytes", 100 * 1024^2)`. It is then extracted into a fresh temporary directory; the manifest's paths must point inside it, under `attachments/` or `objects/`, at regular files. `snap_attachment(x, id)` returns the extracted paths. The snapshot's `inputs` never contain file input values, because a browser's file input cannot be set programmatically; restore hooks can read the attachment instead. ```{r} upload <- tempfile(fileext = ".csv") writeLines(c("x,y", "1,2"), upload) snap <- list( inputs = list(n = 1L), attachments = list(data = list( name = "data.csv", size = file.size(upload), type = "text/csv", datapath = upload )) ) bundle <- tempfile(fileext = ".zip") snap_write(snap, bundle) back <- snap_read(bundle) readLines(snap_attachment(back, "data")) ``` ## The `.rds` format `snap_write(format = "rds")` stores the R object with `saveRDS()`. It is neither readable nor safe across versions, and `snap_read()` refuses it unless `trust = TRUE`, because unserializing a file runs arbitrary code paths. It exists for people who really want it.