Skip to content

Commit 8210fbe

Browse files
committed
Web SDK: protocol conformance, signed cache, 6.5 KB gzipped
0 parents  commit 8210fbe

19 files changed

Lines changed: 5882 additions & 0 deletions

.github/workflows/ci.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with: { node-version: 22 }
15+
- run: npm install
16+
- run: npm run typecheck
17+
# The golden vectors are the contract between every Ripstop SDK. Red here
18+
# means this SDK disagrees with the protocol, not the other way round.
19+
- run: npm test
20+
- run: npm run build

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
dist/
3+
*.log
4+
.DS_Store

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
## 0.1.0
4+
5+
First release.
6+
7+
- Force update, soft update, kill switch and maintenance mode, decided by the
8+
protocol's evaluation order and verified against the golden vectors.
9+
- Remote config values in the same signed payload — no extra request.
10+
- Ed25519 verification of the exact response bytes, with pinned keys and
11+
`key_id` rotation.
12+
- Signed cache in localStorage, re-verified on read.
13+
- Snooze accounting per target version, with cooldown.
14+
- 6.5 KB gzipped, no framework, no dependencies beyond `@noble/ed25519`.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Ripstop
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# @ripstop/web
2+
3+
**Force update, kill switch, maintenance mode and remote config for web apps.**
4+
Signed at the edge, verified in the browser, 6.5 KB gzipped.
5+
6+
[![npm](https://img.shields.io/npm/v/@ripstop/web.svg)](https://www.npmjs.com/package/@ripstop/web)
7+
[![CI](https://github.com/ripstop-dev/ripstop-web/actions/workflows/ci.yaml/badge.svg)](https://github.com/ripstop-dev/ripstop-web/actions/workflows/ci.yaml)
8+
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9+
10+
## Install
11+
12+
```bash
13+
npm install @ripstop/web
14+
```
15+
16+
## Quickstart
17+
18+
```ts
19+
import { Ripstop } from '@ripstop/web';
20+
21+
const ripstop = await Ripstop.init({
22+
apiKey: 'rs_pub_your_key',
23+
appVersion: __APP_VERSION__, // whatever your build injects
24+
});
25+
26+
const decision = await ripstop.check();
27+
28+
switch (decision.type) {
29+
case 'kill':
30+
// This build has been withdrawn.
31+
break;
32+
case 'maintenance':
33+
// You are down on purpose. decision.endsAt is display-only.
34+
break;
35+
case 'force':
36+
// This tab is too old to keep talking to your API.
37+
location.reload();
38+
break;
39+
case 'soft':
40+
// A nudge. ripstop.snooze() records it and re-evaluates.
41+
break;
42+
case 'none':
43+
// Carry on.
44+
break;
45+
}
46+
```
47+
48+
## What `force` means on the web
49+
50+
On mobile it means "this binary is stale, go to the store". On the web there is
51+
no store: a reload gets the latest code. So `force` here almost always means a
52+
tab that has been open since before you shipped a breaking change, and the right
53+
response is usually `location.reload()`.
54+
55+
That is also why the web SDK ships no prebuilt walls. On mobile, a full-screen
56+
update wall is the same shape in every app. On the web it is a banner, a modal,
57+
or a route — entirely yours. You get the decision and the copy you wrote in the
58+
panel; the markup is your business.
59+
60+
## Remote config
61+
62+
Values ride in the same signed payload as the rules, so reading one costs no
63+
extra request and cannot be out of step with them.
64+
65+
```ts
66+
const checkout = ripstop.value('checkout_enabled', true);
67+
const limit = ripstop.value('upload_limit', 10);
68+
```
69+
70+
Always pass a fallback. On a first load with no network there is no payload yet
71+
— that is the fail-open path working as intended.
72+
73+
## What it does when things break
74+
75+
| Situation | What your app does |
76+
| --- | --- |
77+
| Network unavailable | Uses the last **signed** payload from localStorage |
78+
| No network, no cache | `none` — your app runs, unrestricted |
79+
| Edge returns 5xx, or times out | Cache, then normal |
80+
| Signature doesn't verify | Discarded. A forged payload can never kill your app |
81+
| localStorage edited in devtools | Re-verified on read, so it grants nothing |
82+
| Kill switch on, then network lost | The kill **stays**, until a fresh signed payload clears it |
83+
84+
The cache is re-verified every time it is read. This matters more on the web
85+
than anywhere else: `localStorage` is two keystrokes away in devtools, so a
86+
cache that were trusted would make the kill switch a polite request.
87+
88+
## Options
89+
90+
| | Default | |
91+
| --- | --- | --- |
92+
| `apiKey` || Your app's public SDK key. Safe to ship |
93+
| `appVersion` || The build you are running; rules evaluate against it |
94+
| `locale` | `en` | Which wall copy to resolve; falls back to `en` per key |
95+
| `minFetchInterval` | 6 hours | How long a payload is fresh enough to skip the network |
96+
| `timeoutMs` | 5000 | Fetch budget. After that, cache |
97+
| `storage` | localStorage | Swap for `MemoryStorage`, sessionStorage, your own |
98+
| `signingKeys` | pinned | Override for self-hosted deployments and tests |
99+
| `fetchImpl` | global | Inject your own for tests or a proxy |
100+
101+
## SSR
102+
103+
`Ripstop.init` runs anywhere `fetch` exists. Without `localStorage` it falls
104+
back to in-memory storage automatically, so a server render gets a decision and
105+
simply doesn't persist a cache.
106+
107+
## Conformance
108+
109+
Every Ripstop SDK runs the same `vectors.json` — version ordering, evaluation
110+
order, message fallback, snooze accounting, the fail-open state machine.
111+
`npm test` runs it here. If this package and the reference implementation ever
112+
disagree about a single comparison, CI goes red.
113+
114+
Full docs: **[ripstop.dev/docs/web](https://ripstop.dev/docs/web)**
115+
116+
## License
117+
118+
MIT

eslint.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import js from '@eslint/js';
2+
import tseslint from 'typescript-eslint';
3+
4+
export default tseslint.config(
5+
{ ignores: ['dist/**', 'node_modules/**'] },
6+
js.configs.recommended,
7+
...tseslint.configs.recommended,
8+
{ rules: { '@typescript-eslint/no-explicit-any': 'error' } },
9+
);

0 commit comments

Comments
 (0)