@openleaderboard/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +80 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +138 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
|
|
17
|
+
Copyright 2026 OpenLeaderboard contributors
|
|
18
|
+
|
|
19
|
+
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# OpenLeaderboard — TypeScript SDK
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@openleaderboard/sdk)
|
|
4
|
+
[](https://www.npmjs.com/package/@openleaderboard/sdk)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
7
|
+
A dependency-free client for the [OpenLeaderboard](../../README.md) API. Works in
|
|
8
|
+
**browsers** and **Node 18+** (uses the global Fetch API and Web Crypto).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @openleaderboard/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { LeaderboardClient, NotFoundError } from "@openleaderboard/sdk";
|
|
20
|
+
|
|
21
|
+
const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
|
|
22
|
+
|
|
23
|
+
// Submit (write-behind: durably logged, ranked shortly after).
|
|
24
|
+
await lb.submitScore("high", playerId, 1500);
|
|
25
|
+
|
|
26
|
+
// Read back.
|
|
27
|
+
const me = await lb.getRank("high", playerId); // throws NotFoundError if absent
|
|
28
|
+
const top = await lb.getTop("high", 10);
|
|
29
|
+
const near = await lb.getNeighbors("high", playerId, 5); // me ± 5
|
|
30
|
+
const friends = await lb.getFriends("high", ["alice", "bob"]);
|
|
31
|
+
|
|
32
|
+
// Segmented / windowed reads (window: literal id or "daily"/"weekly"/"monthly").
|
|
33
|
+
await lb.getTop("high", 10, { segment: "region=eu", window: "daily" });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Errors: `NotFoundError` (404) and `LeaderboardError` (other non-2xx, with `.status`).
|
|
37
|
+
|
|
38
|
+
### One-time board setup
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
await lb.createBoard("laptimes", { sortOrder: "asc", updatePolicy: "best" });
|
|
42
|
+
await lb.createBoard("weekly", { windows: [{ kind: "all" }, { kind: "weekly" }] });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Node < 18
|
|
46
|
+
|
|
47
|
+
Pass a fetch implementation:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import fetch from "node-fetch";
|
|
51
|
+
new LeaderboardClient(url, key, { fetch });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Signed submissions (server-side only)
|
|
55
|
+
|
|
56
|
+
`signSubmission` and the client's `signingSecret` option produce HMAC signatures
|
|
57
|
+
matching the server's `SIGNING_SECRET`. **Never put the secret in browser/client
|
|
58
|
+
code** — anyone can read it. Sign from a trusted backend instead. Integer scores
|
|
59
|
+
sign identically to the Go server (cross-validated against the server and
|
|
60
|
+
`openssl`).
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const lb = new LeaderboardClient(url, key, { appId, signingSecret }); // backend only
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Develop
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run typecheck
|
|
70
|
+
npm run build
|
|
71
|
+
npm run test:hmac # offline HMAC cross-check
|
|
72
|
+
npm run test:e2e # against a running server (LB_API_KEY env)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Releasing
|
|
76
|
+
|
|
77
|
+
`package.json` is authoritative. Bump `version` in your PR; when it merges to
|
|
78
|
+
`main`, CI (`.github/workflows/release-sdk.yml`) publishes exactly that version
|
|
79
|
+
to npm. Changes that don't bump the version are a no-op (already published →
|
|
80
|
+
skipped). npm versions are immutable, so each release needs a new version.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenLeaderboard TypeScript SDK.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free client over the Fetch API — runs in browsers and Node 18+.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { LeaderboardClient } from "@openleaderboard/sdk";
|
|
8
|
+
* const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
|
|
9
|
+
* await lb.submitScore("high", playerId, 1500);
|
|
10
|
+
* const me = await lb.getRank("high", playerId);
|
|
11
|
+
* const top = await lb.getTop("high", 10);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export interface RankEntry {
|
|
15
|
+
member: string;
|
|
16
|
+
score: number;
|
|
17
|
+
rank: number;
|
|
18
|
+
exact: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SubmitResult {
|
|
21
|
+
accepted: boolean;
|
|
22
|
+
duplicate: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Selects the physical board (segment/window) to read. */
|
|
25
|
+
export interface QueryOpts {
|
|
26
|
+
segment?: string;
|
|
27
|
+
/** Literal window id ("d=2026-06-13") or cadence keyword ("daily"/"weekly"/"monthly"). */
|
|
28
|
+
window?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SubmitOpts {
|
|
31
|
+
segments?: string[];
|
|
32
|
+
idem?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface WindowDef {
|
|
35
|
+
kind: string;
|
|
36
|
+
customId?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface BoardDef {
|
|
39
|
+
sortOrder?: "desc" | "asc";
|
|
40
|
+
updatePolicy?: "best" | "last" | "increment";
|
|
41
|
+
tieBreak?: "lexical" | "firstToReach";
|
|
42
|
+
windows?: WindowDef[];
|
|
43
|
+
}
|
|
44
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
45
|
+
export interface ClientOptions {
|
|
46
|
+
/** App id; required only when signingSecret is set. */
|
|
47
|
+
appId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Optional HMAC secret for signed submissions. Only use in a TRUSTED backend
|
|
50
|
+
* — never ship it in browser/client code.
|
|
51
|
+
*/
|
|
52
|
+
signingSecret?: string;
|
|
53
|
+
/** Override the fetch implementation (e.g. for Node <18 or tests). */
|
|
54
|
+
fetch?: FetchLike;
|
|
55
|
+
}
|
|
56
|
+
/** Thrown for non-2xx responses. */
|
|
57
|
+
export declare class LeaderboardError extends Error {
|
|
58
|
+
readonly status: number;
|
|
59
|
+
constructor(status: number, message: string);
|
|
60
|
+
}
|
|
61
|
+
/** Thrown when a member or board does not exist (HTTP 404). */
|
|
62
|
+
export declare class NotFoundError extends LeaderboardError {
|
|
63
|
+
constructor(message: string);
|
|
64
|
+
}
|
|
65
|
+
export declare class LeaderboardClient {
|
|
66
|
+
private readonly apiKey;
|
|
67
|
+
private readonly opts;
|
|
68
|
+
private readonly baseUrl;
|
|
69
|
+
private readonly fetchFn;
|
|
70
|
+
constructor(baseUrl: string, apiKey: string, opts?: ClientOptions);
|
|
71
|
+
/** Define a board. Typically a one-time setup call. */
|
|
72
|
+
createBoard(board: string, def?: BoardDef): Promise<void>;
|
|
73
|
+
/** Submit a score (write-behind: durably logged, ranked shortly after). */
|
|
74
|
+
submitScore(board: string, member: string, score: number, opts?: SubmitOpts): Promise<SubmitResult>;
|
|
75
|
+
/** A member's rank. Throws {@link NotFoundError} if absent. */
|
|
76
|
+
getRank(board: string, member: string, q?: QueryOpts): Promise<RankEntry>;
|
|
77
|
+
/** Top N entries (rank 1..N). */
|
|
78
|
+
getTop(board: string, n: number, q?: QueryOpts): Promise<RankEntry[]>;
|
|
79
|
+
/** A page of the ranking starting at offset (0-based). */
|
|
80
|
+
getPage(board: string, offset: number, limit: number, q?: QueryOpts): Promise<RankEntry[]>;
|
|
81
|
+
/** The member plus up to k entries on each side of it. */
|
|
82
|
+
getNeighbors(board: string, member: string, k: number, q?: QueryOpts): Promise<RankEntry[]>;
|
|
83
|
+
/** Rank an explicit set of members against each other (a friend leaderboard). */
|
|
84
|
+
getFriends(board: string, members: string[], q?: QueryOpts): Promise<RankEntry[]>;
|
|
85
|
+
private send;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Produces an HMAC-SHA256 submission signature matching the server (pkg/trust).
|
|
89
|
+
* Exported for trusted server-side signing and testing. Matches Go's float
|
|
90
|
+
* formatting for the common integer-score case. (Very large/small fractional
|
|
91
|
+
* scores may format with an exponent in JS and would not match — sign integer
|
|
92
|
+
* scores, or sign server-side.)
|
|
93
|
+
*/
|
|
94
|
+
export declare function signSubmission(secret: string, app: string, board: string, member: string, score: number, ts: number, nonce: string): Promise<string>;
|
|
95
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenLeaderboard TypeScript SDK.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free client over the Fetch API — runs in browsers and Node 18+.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { LeaderboardClient } from "@openleaderboard/sdk";
|
|
8
|
+
* const lb = new LeaderboardClient("https://lb.example.com", "lb_your_api_key");
|
|
9
|
+
* await lb.submitScore("high", playerId, 1500);
|
|
10
|
+
* const me = await lb.getRank("high", playerId);
|
|
11
|
+
* const top = await lb.getTop("high", 10);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
/** Thrown for non-2xx responses. */
|
|
15
|
+
export class LeaderboardError extends Error {
|
|
16
|
+
constructor(status, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.name = "LeaderboardError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Thrown when a member or board does not exist (HTTP 404). */
|
|
23
|
+
export class NotFoundError extends LeaderboardError {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(404, message);
|
|
26
|
+
this.name = "NotFoundError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class LeaderboardClient {
|
|
30
|
+
constructor(baseUrl, apiKey, opts = {}) {
|
|
31
|
+
this.apiKey = apiKey;
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
34
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
35
|
+
if (!f) {
|
|
36
|
+
throw new Error("global fetch unavailable; pass opts.fetch (Node <18)");
|
|
37
|
+
}
|
|
38
|
+
this.fetchFn = f;
|
|
39
|
+
}
|
|
40
|
+
/** Define a board. Typically a one-time setup call. */
|
|
41
|
+
async createBoard(board, def = {}) {
|
|
42
|
+
await this.send("POST", "/v1/boards", {
|
|
43
|
+
board,
|
|
44
|
+
sort_order: def.sortOrder,
|
|
45
|
+
update_policy: def.updatePolicy,
|
|
46
|
+
tie_break: def.tieBreak,
|
|
47
|
+
windows: def.windows?.map((w) => ({ kind: w.kind, custom_id: w.customId })),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Submit a score (write-behind: durably logged, ranked shortly after). */
|
|
51
|
+
async submitScore(board, member, score, opts = {}) {
|
|
52
|
+
const body = {
|
|
53
|
+
member,
|
|
54
|
+
score,
|
|
55
|
+
segments: opts.segments,
|
|
56
|
+
idem: opts.idem,
|
|
57
|
+
};
|
|
58
|
+
if (this.opts.signingSecret) {
|
|
59
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
60
|
+
const nonce = randomNonce();
|
|
61
|
+
body.ts = ts;
|
|
62
|
+
body.nonce = nonce;
|
|
63
|
+
body.sig = await signSubmission(this.opts.signingSecret, this.opts.appId ?? "", board, member, score, ts, nonce);
|
|
64
|
+
}
|
|
65
|
+
const r = await this.send("POST", `/v1/boards/${enc(board)}/scores`, body);
|
|
66
|
+
return { accepted: !!r.accepted, duplicate: !!r.duplicate };
|
|
67
|
+
}
|
|
68
|
+
/** A member's rank. Throws {@link NotFoundError} if absent. */
|
|
69
|
+
async getRank(board, member, q = {}) {
|
|
70
|
+
return this.send("GET", `/v1/boards/${enc(board)}/rank${qs({ member, ...q })}`);
|
|
71
|
+
}
|
|
72
|
+
/** Top N entries (rank 1..N). */
|
|
73
|
+
async getTop(board, n, q = {}) {
|
|
74
|
+
const r = await this.send("GET", `/v1/boards/${enc(board)}/top${qs({ n: String(n), ...q })}`);
|
|
75
|
+
return r.entries ?? [];
|
|
76
|
+
}
|
|
77
|
+
/** A page of the ranking starting at offset (0-based). */
|
|
78
|
+
async getPage(board, offset, limit, q = {}) {
|
|
79
|
+
const r = await this.send("GET", `/v1/boards/${enc(board)}/page${qs({ offset: String(offset), limit: String(limit), ...q })}`);
|
|
80
|
+
return r.entries ?? [];
|
|
81
|
+
}
|
|
82
|
+
/** The member plus up to k entries on each side of it. */
|
|
83
|
+
async getNeighbors(board, member, k, q = {}) {
|
|
84
|
+
const r = await this.send("GET", `/v1/boards/${enc(board)}/neighbors${qs({ member, k: String(k), ...q })}`);
|
|
85
|
+
return r.entries ?? [];
|
|
86
|
+
}
|
|
87
|
+
/** Rank an explicit set of members against each other (a friend leaderboard). */
|
|
88
|
+
async getFriends(board, members, q = {}) {
|
|
89
|
+
const r = await this.send("POST", `/v1/boards/${enc(board)}/friends${qs({ ...q })}`, { members });
|
|
90
|
+
return r.entries ?? [];
|
|
91
|
+
}
|
|
92
|
+
async send(method, path, body) {
|
|
93
|
+
const headers = { Authorization: `Bearer ${this.apiKey}` };
|
|
94
|
+
let bodyStr;
|
|
95
|
+
if (body !== undefined) {
|
|
96
|
+
headers["Content-Type"] = "application/json";
|
|
97
|
+
bodyStr = JSON.stringify(body);
|
|
98
|
+
}
|
|
99
|
+
const resp = await this.fetchFn(this.baseUrl + path, { method, headers, body: bodyStr });
|
|
100
|
+
const text = await resp.text();
|
|
101
|
+
if (resp.status === 404)
|
|
102
|
+
throw new NotFoundError(text);
|
|
103
|
+
if (!resp.ok)
|
|
104
|
+
throw new LeaderboardError(resp.status, `${method} ${path} -> ${resp.status}: ${text}`);
|
|
105
|
+
return text ? JSON.parse(text) : {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function enc(s) {
|
|
109
|
+
return encodeURIComponent(s);
|
|
110
|
+
}
|
|
111
|
+
function qs(params) {
|
|
112
|
+
const p = new URLSearchParams();
|
|
113
|
+
for (const [k, v] of Object.entries(params)) {
|
|
114
|
+
if (v != null && v !== "")
|
|
115
|
+
p.set(k, v);
|
|
116
|
+
}
|
|
117
|
+
const s = p.toString();
|
|
118
|
+
return s ? `?${s}` : "";
|
|
119
|
+
}
|
|
120
|
+
function randomNonce() {
|
|
121
|
+
const a = new Uint8Array(16);
|
|
122
|
+
globalThis.crypto.getRandomValues(a);
|
|
123
|
+
return [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Produces an HMAC-SHA256 submission signature matching the server (pkg/trust).
|
|
127
|
+
* Exported for trusted server-side signing and testing. Matches Go's float
|
|
128
|
+
* formatting for the common integer-score case. (Very large/small fractional
|
|
129
|
+
* scores may format with an exponent in JS and would not match — sign integer
|
|
130
|
+
* scores, or sign server-side.)
|
|
131
|
+
*/
|
|
132
|
+
export async function signSubmission(secret, app, board, member, score, ts, nonce) {
|
|
133
|
+
const canonical = [app, board, member, String(score), String(ts), nonce].join("\n");
|
|
134
|
+
const encb = new TextEncoder();
|
|
135
|
+
const key = await globalThis.crypto.subtle.importKey("raw", encb.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
136
|
+
const sig = await globalThis.crypto.subtle.sign("HMAC", key, encb.encode(canonical));
|
|
137
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
138
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openleaderboard/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript client for the OpenLeaderboard API (browser + Node 18+).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
|
+
"test:e2e": "node test/e2e.mjs",
|
|
20
|
+
"test:hmac": "node test/hmac.mjs",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["leaderboard", "gaming", "ranking", "api"],
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/kodeni-am/leaderboard.git",
|
|
28
|
+
"directory": "sdk/typescript"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/kodeni-am/leaderboard/tree/main/sdk/typescript#readme",
|
|
31
|
+
"bugs": { "url": "https://github.com/kodeni-am/leaderboard/issues" },
|
|
32
|
+
"publishConfig": { "access": "public" },
|
|
33
|
+
"engines": { "node": ">=18" },
|
|
34
|
+
"devDependencies": { "typescript": "^5.5.0" }
|
|
35
|
+
}
|