@yaebal/callback-data 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neverlane
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.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @yaebal/callback-data
2
+
3
+ typed `callback_data`. define a prefix + a field→codec schema, then `pack`/`unpack`
4
+ with full type inference. values are URL-encoded so the `:` separator is safe inside
5
+ them. telegram caps callback_data at 64 bytes — keeping it short is the caller's job.
6
+
7
+ codecs are not validated: `number` must be finite (garbage decodes to `NaN`), and
8
+ `boolean` parses by exact `"true"` (anything else → `false`). safe for round-tripping
9
+ data you packed yourself, which is all callback_data ever is.
10
+
11
+ ## install
12
+
13
+ ```sh
14
+ pnpm add @yaebal/callback-data
15
+ ```
16
+
17
+ ---
18
+
19
+ part of [**yaebal**](https://github.com/neverlane/yaebal) — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
package/lib/index.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Typed `callback_data`. Define a prefix + a field→codec schema, then `pack`/`unpack`
3
+ * with full type inference. Values are URL-encoded so the `:` separator is safe inside
4
+ * them. Telegram caps callback_data at 64 bytes — keeping it short is the caller's job.
5
+ *
6
+ * Codecs are not validated: `Number` must be finite (garbage decodes to `NaN`), and
7
+ * `Boolean` parses by exact `"true"` (anything else → `false`). Safe for round-tripping
8
+ * data you packed yourself, which is all callback_data ever is.
9
+ */
10
+ type Codec = NumberConstructor | StringConstructor | BooleanConstructor;
11
+ type Infer<S extends Record<string, Codec>> = {
12
+ [K in keyof S]: S[K] extends NumberConstructor ? number : S[K] extends BooleanConstructor ? boolean : string;
13
+ };
14
+ export interface CallbackData<S extends Record<string, Codec>> {
15
+ /** Serialize a payload into a callback_data string. */
16
+ pack(data: Infer<S>): string;
17
+ /** Parse a callback_data string, or `undefined` if the prefix/arity doesn't match. */
18
+ unpack(raw: string): Infer<S> | undefined;
19
+ /** True if `raw` belongs to this callback_data namespace. */
20
+ filter(raw: string | undefined): boolean;
21
+ /** RegExp anchored to the prefix — pass to `bot.callbackQuery(cd.pattern, …)`. */
22
+ readonly pattern: RegExp;
23
+ }
24
+ export declare function callbackData<S extends Record<string, Codec>>(prefix: string, schema: S): CallbackData<S>;
25
+ export {};
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,KAAK,KAAK,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,kBAAkB,CAAC;AAExE,KAAK,KAAK,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI;KAC5C,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,iBAAiB,GAC3C,MAAM,GACN,CAAC,CAAC,CAAC,CAAC,SAAS,kBAAkB,GAC9B,OAAO,GACP,MAAM;CACV,CAAC;AAEF,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;IAC5D,uDAAuD;IACvD,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC;IAC7B,sFAAsF;IACtF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC1C,6DAA6D;IAC7D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACzC,kFAAkF;IAClF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,EAC3D,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,CAAC,GACP,YAAY,CAAC,CAAC,CAAC,CAuCjB"}
package/lib/index.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Typed `callback_data`. Define a prefix + a field→codec schema, then `pack`/`unpack`
3
+ * with full type inference. Values are URL-encoded so the `:` separator is safe inside
4
+ * them. Telegram caps callback_data at 64 bytes — keeping it short is the caller's job.
5
+ *
6
+ * Codecs are not validated: `Number` must be finite (garbage decodes to `NaN`), and
7
+ * `Boolean` parses by exact `"true"` (anything else → `false`). Safe for round-tripping
8
+ * data you packed yourself, which is all callback_data ever is.
9
+ */
10
+ export function callbackData(prefix, schema) {
11
+ if (prefix.includes(":")) {
12
+ throw new Error(`callbackData: prefix "${prefix}" must not contain ":" (the field separator)`);
13
+ }
14
+ const keys = Object.keys(schema);
15
+ const pattern = new RegExp(`^${escapeRegExp(prefix)}(?::|$)`);
16
+ return {
17
+ pattern,
18
+ pack(data) {
19
+ const parts = keys.map((k) => encodeURIComponent(String(data[k])));
20
+ return [prefix, ...parts].join(":");
21
+ },
22
+ unpack(raw) {
23
+ const segs = raw.split(":");
24
+ if (segs[0] !== prefix)
25
+ return undefined;
26
+ const values = segs.slice(1);
27
+ if (values.length !== keys.length)
28
+ return undefined;
29
+ const out = {};
30
+ keys.forEach((k, i) => {
31
+ const dec = decodeURIComponent(values[i] ?? "");
32
+ const codec = schema[k];
33
+ out[k] = codec === Number ? Number(dec) : codec === Boolean ? dec === "true" : dec;
34
+ });
35
+ return out;
36
+ },
37
+ filter(raw) {
38
+ return raw !== undefined && pattern.test(raw);
39
+ },
40
+ };
41
+ }
42
+ function escapeRegExp(s) {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
45
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuBH,MAAM,UAAU,YAAY,CAC3B,MAAc,EACd,MAAS;IAET,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,yBAAyB,MAAM,8CAA8C,CAAC,CAAC;IAChG,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAyB,CAAC;IACzD,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAE9D,OAAO;QACN,OAAO;QAEP,IAAI,CAAC,IAAI;YACR,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACnE,OAAO,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACrC,CAAC;QAED,MAAM,CAAC,GAAG;YACT,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YAC5B,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM;gBAAE,OAAO,SAAS,CAAC;YAEzC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC7B,IAAI,MAAM,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;gBAAE,OAAO,SAAS,CAAC;YAEpD,MAAM,GAAG,GAAG,EAA6B,CAAC;YAE1C,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;gBACrB,MAAM,GAAG,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;gBAChD,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;gBAExB,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC;YACpF,CAAC,CAAC,CAAC;YAEH,OAAO,GAAe,CAAC;QACxB,CAAC;QAED,MAAM,CAAC,GAAG;YACT,OAAO,GAAG,KAAK,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/C,CAAC;KACD,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AACjD,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { callbackData } from "./index.js";
4
+ const cd = callbackData("user", { id: Number, action: String, admin: Boolean });
5
+ test("round-trips with typed coercion", () => {
6
+ const raw = cd.pack({ id: 5, action: "ban", admin: true });
7
+ assert.equal(raw, "user:5:ban:true");
8
+ assert.deepEqual(cd.unpack(raw), { id: 5, action: "ban", admin: true });
9
+ });
10
+ test("rejects a foreign prefix", () => {
11
+ assert.equal(cd.unpack("other:1:x:false"), undefined);
12
+ });
13
+ test("rejects wrong arity", () => {
14
+ assert.equal(cd.unpack("user:1"), undefined);
15
+ });
16
+ test("separator inside a value survives encoding", () => {
17
+ const raw = cd.pack({ id: 1, action: "a:b:c", admin: false });
18
+ assert.deepEqual(cd.unpack(raw), { id: 1, action: "a:b:c", admin: false });
19
+ });
20
+ test("pattern/filter matches its own namespace", () => {
21
+ assert.ok(cd.filter(cd.pack({ id: 1, action: "x", admin: false })));
22
+ assert.equal(cd.filter("nope:1"), false);
23
+ assert.equal(cd.filter(undefined), false);
24
+ });
25
+ test("rejects a prefix containing the separator", () => {
26
+ assert.throws(() => callbackData("a:b", { x: Number }), /must not contain ":"/);
27
+ });
28
+ test("prefix-only schema matches exactly", () => {
29
+ const ping = callbackData("ping", {});
30
+ assert.equal(ping.pack({}), "ping");
31
+ assert.deepEqual(ping.unpack("ping"), {});
32
+ assert.ok(ping.filter("ping"));
33
+ assert.equal(ping.unpack("pinger"), undefined);
34
+ });
35
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@yaebal/callback-data",
3
+ "version": "0.0.1",
4
+ "description": "yaebal typed callback_data — pack/unpack button payloads with type-safe schemas.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "devDependencies": {
19
+ "@types/node": "latest"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "keywords": [
25
+ "telegram",
26
+ "telegram-bot",
27
+ "yaebal",
28
+ "callback-data"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/neverlane/yaebal",
34
+ "directory": "packages/callback-data"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "test": "node --test lib"
43
+ }
44
+ }
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { callbackData } from "./index.js";
4
+
5
+ const cd = callbackData("user", { id: Number, action: String, admin: Boolean });
6
+
7
+ test("round-trips with typed coercion", () => {
8
+ const raw = cd.pack({ id: 5, action: "ban", admin: true });
9
+
10
+ assert.equal(raw, "user:5:ban:true");
11
+ assert.deepEqual(cd.unpack(raw), { id: 5, action: "ban", admin: true });
12
+ });
13
+
14
+ test("rejects a foreign prefix", () => {
15
+ assert.equal(cd.unpack("other:1:x:false"), undefined);
16
+ });
17
+
18
+ test("rejects wrong arity", () => {
19
+ assert.equal(cd.unpack("user:1"), undefined);
20
+ });
21
+
22
+ test("separator inside a value survives encoding", () => {
23
+ const raw = cd.pack({ id: 1, action: "a:b:c", admin: false });
24
+ assert.deepEqual(cd.unpack(raw), { id: 1, action: "a:b:c", admin: false });
25
+ });
26
+
27
+ test("pattern/filter matches its own namespace", () => {
28
+ assert.ok(cd.filter(cd.pack({ id: 1, action: "x", admin: false })));
29
+ assert.equal(cd.filter("nope:1"), false);
30
+ assert.equal(cd.filter(undefined), false);
31
+ });
32
+
33
+ test("rejects a prefix containing the separator", () => {
34
+ assert.throws(() => callbackData("a:b", { x: Number }), /must not contain ":"/);
35
+ });
36
+
37
+ test("prefix-only schema matches exactly", () => {
38
+ const ping = callbackData("ping", {});
39
+
40
+ assert.equal(ping.pack({}), "ping");
41
+ assert.deepEqual(ping.unpack("ping"), {});
42
+ assert.ok(ping.filter("ping"));
43
+ assert.equal(ping.unpack("pinger"), undefined);
44
+ });
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Typed `callback_data`. Define a prefix + a field→codec schema, then `pack`/`unpack`
3
+ * with full type inference. Values are URL-encoded so the `:` separator is safe inside
4
+ * them. Telegram caps callback_data at 64 bytes — keeping it short is the caller's job.
5
+ *
6
+ * Codecs are not validated: `Number` must be finite (garbage decodes to `NaN`), and
7
+ * `Boolean` parses by exact `"true"` (anything else → `false`). Safe for round-tripping
8
+ * data you packed yourself, which is all callback_data ever is.
9
+ */
10
+
11
+ type Codec = NumberConstructor | StringConstructor | BooleanConstructor;
12
+
13
+ type Infer<S extends Record<string, Codec>> = {
14
+ [K in keyof S]: S[K] extends NumberConstructor
15
+ ? number
16
+ : S[K] extends BooleanConstructor
17
+ ? boolean
18
+ : string;
19
+ };
20
+
21
+ export interface CallbackData<S extends Record<string, Codec>> {
22
+ /** Serialize a payload into a callback_data string. */
23
+ pack(data: Infer<S>): string;
24
+ /** Parse a callback_data string, or `undefined` if the prefix/arity doesn't match. */
25
+ unpack(raw: string): Infer<S> | undefined;
26
+ /** True if `raw` belongs to this callback_data namespace. */
27
+ filter(raw: string | undefined): boolean;
28
+ /** RegExp anchored to the prefix — pass to `bot.callbackQuery(cd.pattern, …)`. */
29
+ readonly pattern: RegExp;
30
+ }
31
+
32
+ export function callbackData<S extends Record<string, Codec>>(
33
+ prefix: string,
34
+ schema: S,
35
+ ): CallbackData<S> {
36
+ if (prefix.includes(":")) {
37
+ throw new Error(`callbackData: prefix "${prefix}" must not contain ":" (the field separator)`);
38
+ }
39
+
40
+ const keys = Object.keys(schema) as (keyof S & string)[];
41
+ const pattern = new RegExp(`^${escapeRegExp(prefix)}(?::|$)`);
42
+
43
+ return {
44
+ pattern,
45
+
46
+ pack(data) {
47
+ const parts = keys.map((k) => encodeURIComponent(String(data[k])));
48
+ return [prefix, ...parts].join(":");
49
+ },
50
+
51
+ unpack(raw) {
52
+ const segs = raw.split(":");
53
+ if (segs[0] !== prefix) return undefined;
54
+
55
+ const values = segs.slice(1);
56
+ if (values.length !== keys.length) return undefined;
57
+
58
+ const out = {} as Record<string, unknown>;
59
+
60
+ keys.forEach((k, i) => {
61
+ const dec = decodeURIComponent(values[i] ?? "");
62
+ const codec = schema[k];
63
+
64
+ out[k] = codec === Number ? Number(dec) : codec === Boolean ? dec === "true" : dec;
65
+ });
66
+
67
+ return out as Infer<S>;
68
+ },
69
+
70
+ filter(raw) {
71
+ return raw !== undefined && pattern.test(raw);
72
+ },
73
+ };
74
+ }
75
+
76
+ function escapeRegExp(s: string): string {
77
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }