Skip to content

Commit 53fa95d

Browse files
committed
docs: README, typechecked CI, spoo-ts repo identity
CI runs typecheck, tests, build and package lint, plus a spec-drift job that pins the committed openapi.json to the backend's and fails when generated types are stale. Examples stop showcasing features still behind flags.
1 parent 0c36283 commit 53fa95d

5 files changed

Lines changed: 259 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v7
19+
20+
- name: Set up Node
21+
uses: actions/setup-node@v6
22+
with:
23+
node-version: 22
24+
cache: npm
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Typecheck (src, tests, examples)
30+
run: npx tsc --noEmit
31+
32+
- name: Tests
33+
run: npm test
34+
35+
- name: Build
36+
run: npm run build
37+
38+
- name: Package lint
39+
run: npx publint && npx attw --pack .
40+
41+
spec-drift:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- name: Checkout
45+
uses: actions/checkout@v7
46+
47+
- name: Set up Node
48+
uses: actions/setup-node@v6
49+
with:
50+
node-version: 22
51+
cache: npm
52+
53+
- name: Install dependencies
54+
run: npm ci
55+
56+
- name: Diff committed spec against the backend's
57+
run: |
58+
curl -fsSL https://raw.githubusercontent.com/spoo-me/spoo/main/openapi.json -o upstream-openapi.json
59+
diff -q upstream-openapi.json openapi.json || {
60+
echo "::error::openapi.json is behind spoo-me/spoo main. Copy the upstream file and run npm run gen:types."
61+
exit 1
62+
}
63+
64+
- name: Verify generated types are current
65+
run: |
66+
npm run gen:types
67+
git diff --exit-code src/generated/schema.d.ts || {
68+
echo "::error::src/generated/schema.d.ts is stale. Run npm run gen:types and commit the result."
69+
exit 1
70+
}

README.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# spoo.me TypeScript SDK
2+
3+
The official TypeScript SDK for the [spoo.me](https://spoo.me) link management API.
4+
5+
```ts
6+
import { Spoo } from "spoo.me";
7+
8+
const spoo = new Spoo({ apiKey: "spoo_..." });
9+
10+
const link = await spoo.links.create({ long_url: "https://example.com/launch" });
11+
console.log(link.short_url); // https://spoo.me/xyz
12+
```
13+
14+
- Zero runtime dependencies, built on global `fetch`
15+
- Runs on Node 20+, Cloudflare Workers, Vercel Edge, Deno, Bun and browsers
16+
- Typed errors, automatic retries, async-iterator pagination
17+
- Types generated from the API's OpenAPI spec, so they cannot drift
18+
19+
## Install
20+
21+
```sh
22+
npm install spoo.me
23+
```
24+
25+
## Authentication
26+
27+
Create an API key from your [spoo.me dashboard](https://spoo.me). The client
28+
reads `SPOO_API_KEY` from the environment, or takes the key explicitly:
29+
30+
```ts
31+
const spoo = new Spoo(); // uses SPOO_API_KEY
32+
const spoo = new Spoo({ apiKey: "spoo_..." }); // explicit
33+
const spoo = new Spoo({ token: async () => myJwt }); // app tokens
34+
```
35+
36+
Constructing without credentials is valid too: anonymous shortening and the
37+
public endpoints work without an account.
38+
39+
Self-hosting spoo.me? Point the client at your instance with
40+
`new Spoo({ baseUrl: "https://links.example.com" })`.
41+
42+
## Shorten links
43+
44+
```ts
45+
const link = await spoo.links.create({
46+
long_url: "https://example.com/launch",
47+
alias: "launch", // or emoji: "🚀🔥"
48+
password: "optional-password",
49+
max_clicks: 10_000,
50+
expire_after: new Date("2026-12-31T23:59:59Z"),
51+
});
52+
```
53+
54+
Timestamps are accepted as `Date`, ISO 8601 strings, or unix epoch seconds
55+
everywhere, and returned as `Date` objects everywhere.
56+
57+
Anonymous creations return a one-time `claim_token`. Store it and the link can
58+
be claimed into an account later with `spoo.links.claim()`.
59+
60+
## Manage links
61+
62+
```ts
63+
const link = await spoo.links.get(id);
64+
await spoo.links.update(id, { max_clicks: 500 });
65+
await spoo.links.setStatus(id, "INACTIVE");
66+
await spoo.links.delete(id);
67+
```
68+
69+
Bulk operations take up to 100 ids and report per-item results instead of
70+
throwing, so a partial failure never aborts the batch:
71+
72+
```ts
73+
const result = await spoo.links.bulk.setStatus(ids, "INACTIVE");
74+
console.log(result.summary); // { total, succeeded, failed }
75+
```
76+
77+
## Pagination
78+
79+
Every list is a `Page`: use it directly, walk it by hand, or iterate items
80+
across all pages with `for await`.
81+
82+
```ts
83+
for await (const link of await spoo.links.list({ sortBy: "total_clicks" })) {
84+
console.log(link.alias, link.total_clicks);
85+
}
86+
```
87+
88+
## Analytics
89+
90+
```ts
91+
// Aggregate across everything you own, sliced and filtered
92+
const stats = await spoo.stats.get({
93+
startDate: new Date("2026-01-01"),
94+
groupBy: ["time", "country"],
95+
device: ["mobile"],
96+
timezone: "Asia/Kolkata",
97+
});
98+
99+
// One link, by id
100+
const one = await spoo.stats.getForLink(id, { groupBy: ["referrer"] });
101+
102+
// File exports (csv is a ZIP archive with one CSV per dimension)
103+
const file = await spoo.stats.export({ groupBy: ["country"] }, "xlsx");
104+
```
105+
106+
Public per-link stats need no account:
107+
108+
```ts
109+
const stats = await spoo.public.stats("alias");
110+
const preview = await spoo.public.preview("alias");
111+
```
112+
113+
## Errors
114+
115+
Failed requests throw a typed subclass of `SpooError`:
116+
117+
| Status | Class |
118+
| --- | --- |
119+
| 400, 422 | `ValidationError` |
120+
| 401 | `AuthenticationError` |
121+
| 403 | `ForbiddenError` |
122+
| 404 | `NotFoundError` |
123+
| 409 | `ConflictError` |
124+
| 410 | `GoneError` |
125+
| 413 | `PayloadTooLargeError` |
126+
| 429 | `RateLimitError` |
127+
| 451 | `ContentBlockedError` |
128+
| 5xx | `InternalServerError`, `ServiceUnavailableError` |
129+
| (no response) | `APIConnectionError`, `APITimeoutError` |
130+
131+
Every error carries the machine-readable `code` from the API (a typed union
132+
such as `"password_required"`, `"blocked"`, `"conflict"`), the `requestId` to
133+
quote in support requests, and the response headers. `RateLimitError` also
134+
exposes the parsed rate-limit state:
135+
136+
```ts
137+
try {
138+
await spoo.links.create({ long_url });
139+
} catch (err) {
140+
if (err instanceof RateLimitError) {
141+
console.log(err.rateLimit.retryAfter, err.hint);
142+
}
143+
}
144+
```
145+
146+
## Retries and timeouts
147+
148+
Failed requests are retried twice by default with exponential backoff and
149+
jitter, honoring the `Retry-After` header on 429 responses. Retries and the
150+
60 second timeout are configurable per client and per request:
151+
152+
```ts
153+
const spoo = new Spoo({ maxRetries: 3, timeout: 15_000 });
154+
await spoo.links.get(id, { maxRetries: 0, signal: controller.signal });
155+
```
156+
157+
Requests that are not idempotent are only retried when the server provably
158+
did no work.
159+
160+
## Requirements
161+
162+
Node 20 or later, or any runtime with WHATWG `fetch`: Cloudflare Workers,
163+
Vercel Edge, Deno, Bun, evergreen browsers. The package is ESM only.
164+
165+
Using an API key in a browser exposes it to every visitor, so the client
166+
refuses to start with a key in a browser unless you pass
167+
`dangerouslyAllowBrowser: true`. Keyless anonymous usage needs no flag.
168+
169+
## Versioning
170+
171+
The SDK follows SemVer and is currently 0.x while the surface settles. New
172+
API endpoints and new optional fields ship as minor versions. Response types
173+
can gain fields at any time; the SDK does not validate responses at runtime,
174+
so additive API changes never break an installed version.
175+
176+
## More
177+
178+
Runnable samples live in [`examples/`](./examples). Full API documentation is
179+
at [docs.spoo.me](https://docs.spoo.me).
180+
181+
## License
182+
183+
[AGPL-3.0](./LICENSE)

examples/quickstart.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,14 @@ console.log(link.short_url, "->", link.long_url);
1414
// Check an alias before fighting for it.
1515
const { available } = await spoo.links.checkAlias("launch");
1616

17-
// Everything at once: custom alias, password, click budget, expiry,
18-
// per-country destinations and custom link-preview cards.
19-
// domain / geo_rules / meta_tags need a verified account.
17+
// Custom alias, password protection, click budget and expiry.
2018
if (available) {
2119
await spoo.links.create({
2220
long_url: "https://example.com/launch",
2321
alias: "launch",
2422
password: "hunter2-not-this",
2523
max_clicks: 10_000,
2624
expire_after: new Date("2026-12-31T23:59:59Z"),
27-
geo_rules: { IN: "https://example.com/launch-in" },
28-
meta_tags: { title: "The launch", color: "#0F62FE" },
2925
});
3026
}
3127

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"homepage": "https://spoo.me",
1313
"repository": {
1414
"type": "git",
15-
"url": "git+https://github.com/spoo-me/sdk-ts.git"
15+
"url": "git+https://github.com/spoo-me/spoo-ts.git"
1616
},
1717
"license": "AGPL-3.0-only",
1818
"type": "module",

src/resources/links.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ export class Links {
7878
}
7979

8080
/**
81-
* Shorten a URL. Works unauthenticated; `domain`, `geo_rules` and
82-
* `meta_tags` require an authenticated, email-verified caller. Anonymous
83-
* calls return a one-time `claim_token` for later account claiming.
81+
* Shorten a URL. Works unauthenticated; anonymous calls return a one-time
82+
* `claim_token` for later account claiming. `domain`, `geo_rules` and
83+
* `meta_tags` require a verified account with the matching feature
84+
* enabled, and fail as `feature_disabled` otherwise.
8485
*/
8586
async create(params: CreateLinkParams, opts?: RequestOptions): Promise<CreatedLink> {
8687
const body = {

0 commit comments

Comments
 (0)