@effing/serde 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 ADDED
@@ -0,0 +1,11 @@
1
+ O'Saasy License
2
+
3
+ Copyright © 2026, Trackuity BV.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
10
+
11
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @effing/serde
2
+
3
+ **URL-safe serialization with compression and HMAC signing.**
4
+
5
+ > Part of the [**Effing**](../../README.md) family — programmatic video creation with TypeScript.
6
+
7
+ Serialize JSON data into URL-safe strings with automatic compression and cryptographic signing. Compatible with Python's [itsdangerous](https://itsdangerous.palletsprojects.com/).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @effing/serde
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```typescript
18
+ import { serialize, deserialize } from "@effing/serde";
19
+
20
+ const secret = process.env.SECRET_KEY!;
21
+ const data = { userId: 123, action: "view" };
22
+
23
+ // Signed (and sometimes compressed) URL segment
24
+ const segment = await serialize(data, secret);
25
+
26
+ // Verify signature (and decompress if needed)
27
+ const restored = await deserialize<typeof data>(segment, secret);
28
+ ```
29
+
30
+ ## Concepts
31
+
32
+ ### URL-Safe Base64
33
+
34
+ Standard Base64 uses `+` and `/` which have special meaning in URLs. This package uses URL-safe Base64:
35
+
36
+ - `+` → `-`
37
+ - `/` → `_`
38
+ - No padding (`=`)
39
+
40
+ ### Signed Serialization
41
+
42
+ Serialization is signed with HMAC (default: `sha1`) so the result can safely be used in a URL without being tampered with:
43
+
44
+ ```typescript
45
+ // Server: create signed URL segment
46
+ const segment = await serialize(data, secret);
47
+
48
+ // Client: passes segment in URL
49
+ // Server: verify and deserialize
50
+ const data = await deserialize(segment, secret);
51
+ // Throws if signature is invalid.
52
+ ```
53
+
54
+ ### Compression
55
+
56
+ Payloads are gzip-compressed when it saves space. Compressed payloads are prefixed with a leading `"."` (matching `itsdangerous`).
57
+
58
+ ## API Overview
59
+
60
+ #### `serialize(obj, secretKey, options?)`
61
+
62
+ Serialize a value to a URL-safe string.
63
+
64
+ ```typescript
65
+ function serialize(
66
+ obj: object,
67
+ secretKey: string,
68
+ options?: {
69
+ /** Salt for key derivation (default: "itsdangerous") */
70
+ salt?: string;
71
+ /** Hash algorithm for HMAC (default: "sha1") */
72
+ algorithm?: string;
73
+ },
74
+ ): Promise<string>;
75
+ ```
76
+
77
+ #### `deserialize(segment, secretKey, options?)`
78
+
79
+ Deserialize a URL segment back to a value.
80
+
81
+ ```typescript
82
+ function deserialize<T = Record<string, unknown>>(
83
+ segment: string,
84
+ secretKey: string,
85
+ options?: {
86
+ /** Salt for key derivation (default: "itsdangerous") */
87
+ salt?: string;
88
+ /** Hash algorithm for HMAC (default: "sha1") */
89
+ algorithm?: string;
90
+ /** Convert snake_case keys to camelCase (default: true) */
91
+ convertKeysToCamel?: boolean;
92
+ },
93
+ ): Promise<T>;
94
+ ```
95
+
96
+ **Throws:**
97
+
98
+ - `Error` — If signature verification fails
99
+
100
+ ## Examples
101
+
102
+ ### Passing Props in URLs
103
+
104
+ ```typescript
105
+ import { serialize, deserialize } from "@effing/serde";
106
+
107
+ // Create URL with serialized props
108
+ const secret = process.env.SECRET_KEY!;
109
+ const props = { imageUrl: "https://example.com/image.png", duration: 5 };
110
+ const segment = await serialize(props, secret);
111
+ const url = `/render/${segment}`;
112
+
113
+ // In route handler
114
+ async function loader({ params }) {
115
+ const props = await deserialize(params.segment, secret);
116
+ // props = { imageUrl: "https://example.com/image.png", duration: 5 }
117
+ }
118
+ ```
119
+
120
+ > **Note:** The `convertKeysToCamel` deserialization option (which is `true` by default) is useful when URLs are built in Python (with itsdangerous) and then consumed by Effing. Python typically uses `snake_case` keys, while TypeScript prefers `camelCase`.
121
+
122
+ ### Secure Tokens
123
+
124
+ ```typescript
125
+ const SECRET = process.env.TOKEN_SECRET!;
126
+
127
+ // Create signed token
128
+ async function createToken(userId: number, expiresAt: number) {
129
+ return serialize({ userId, expiresAt }, SECRET);
130
+ }
131
+
132
+ // Verify token
133
+ async function verifyToken(token: string) {
134
+ try {
135
+ const { userId, expiresAt } = await deserialize(token, SECRET);
136
+ if (Date.now() > expiresAt) throw new Error("Token expired");
137
+ return userId;
138
+ } catch (e) {
139
+ throw new Error("Invalid token");
140
+ }
141
+ }
142
+ ```
@@ -0,0 +1,26 @@
1
+ interface SerializeOptions {
2
+ /** Salt for key derivation (default: "itsdangerous") */
3
+ salt?: string;
4
+ /** Hash algorithm for HMAC (default: "sha1") */
5
+ algorithm?: string;
6
+ }
7
+ /**
8
+ * Serialize an object to a URL-safe segment with optional compression and HMAC signature.
9
+ *
10
+ * The format is compatible with Python's itsdangerous library.
11
+ * - If compression saves space, the payload is prefixed with "."
12
+ * - The signature is appended after a "." separator
13
+ */
14
+ declare function serialize(obj: object, secretKey: string, options?: SerializeOptions): Promise<string>;
15
+ interface DeserializeOptions extends SerializeOptions {
16
+ /** Whether to convert snake_case keys to camelCase (default: true) */
17
+ convertKeysToCamel?: boolean;
18
+ }
19
+ /**
20
+ * Deserialize a URL segment, verify its signature, and decompress if needed.
21
+ *
22
+ * Throws an error if the signature is invalid.
23
+ */
24
+ declare function deserialize<T = Record<string, unknown>>(segment: string, secretKey: string, options?: DeserializeOptions): Promise<T>;
25
+
26
+ export { type DeserializeOptions, type SerializeOptions, deserialize, serialize };
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ // src/itsdangerous.ts
2
+ import zlib from "zlib";
3
+ import { promisify } from "util";
4
+ import { createHash, createHmac } from "crypto";
5
+ var zip = promisify(zlib.gzip);
6
+ var unzip = promisify(zlib.unzip);
7
+ function urlsafeBase64Encode(data, unsafeCharsMapping = { "+": "-", "/": "_" }) {
8
+ return Buffer.from(data).toString("base64").replace(/[+/]/g, (m) => unsafeCharsMapping[m]);
9
+ }
10
+ function urlsafeBase64Decode(encoded, unsafeCharsMapping = { "-": "+", _: "/" }) {
11
+ return Buffer.from(
12
+ encoded.replace(/[-_]/g, (m) => unsafeCharsMapping[m]),
13
+ "base64"
14
+ );
15
+ }
16
+ function deriveSigningKey(secretKey, salt, algorithm) {
17
+ return createHash(algorithm).update(`${salt}signer${secretKey}`).digest();
18
+ }
19
+ function keysToCamel(obj) {
20
+ const newObj = {};
21
+ Object.keys(obj).forEach((key) => {
22
+ const camelKey = key.replace(
23
+ /(_[a-z])/gi,
24
+ (s) => s.toUpperCase().replace("_", "")
25
+ );
26
+ newObj[camelKey] = obj[key];
27
+ });
28
+ return newObj;
29
+ }
30
+ async function serialize(obj, secretKey, options = {}) {
31
+ const { salt = "itsdangerous", algorithm = "sha1" } = options;
32
+ let json = Buffer.from(JSON.stringify(obj));
33
+ const compressed = await zip(json);
34
+ let isCompressed = false;
35
+ if (compressed.length < json.length - 1) {
36
+ json = compressed;
37
+ isCompressed = true;
38
+ }
39
+ let encoded = urlsafeBase64Encode(json);
40
+ if (isCompressed) {
41
+ encoded = `.${encoded}`;
42
+ }
43
+ const derivedKey = deriveSigningKey(secretKey, salt, algorithm);
44
+ const hmac = createHmac(algorithm, derivedKey).update(encoded).digest();
45
+ return `${encoded}.${urlsafeBase64Encode(hmac)}`;
46
+ }
47
+ async function deserialize(segment, secretKey, options = {}) {
48
+ const {
49
+ salt = "itsdangerous",
50
+ algorithm = "sha1",
51
+ convertKeysToCamel = true
52
+ } = options;
53
+ const parts = segment.split(".");
54
+ const signature = parts.at(-1);
55
+ let payload = parts.slice(0, -1).join(".");
56
+ const derivedKey = deriveSigningKey(secretKey, salt, algorithm);
57
+ const hmac = createHmac(algorithm, derivedKey).update(payload).digest();
58
+ if (Buffer.compare(urlsafeBase64Decode(signature), hmac) !== 0) {
59
+ throw new Error("invalid url segment signature");
60
+ }
61
+ let decompress = false;
62
+ if (payload.startsWith(".")) {
63
+ decompress = true;
64
+ payload = payload.slice(1);
65
+ }
66
+ const decoded = urlsafeBase64Decode(payload);
67
+ let parsed;
68
+ if (decompress) {
69
+ const decompressed = await unzip(decoded);
70
+ parsed = JSON.parse(decompressed.toString());
71
+ } else {
72
+ parsed = JSON.parse(decoded.toString());
73
+ }
74
+ if (convertKeysToCamel) {
75
+ return keysToCamel(parsed);
76
+ }
77
+ return parsed;
78
+ }
79
+ export {
80
+ deserialize,
81
+ serialize
82
+ };
83
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/itsdangerous.ts"],"sourcesContent":["/**\n * TypeScript implementation of itsdangerous serialization logic.\n * @see https://github.com/pallets/itsdangerous/\n */\n\nimport zlib from \"node:zlib\";\nimport { promisify } from \"node:util\";\nimport { createHash, createHmac } from \"node:crypto\";\n\nconst zip = promisify(zlib.gzip);\nconst unzip = promisify(zlib.unzip);\n\n/**\n * Encode data as URL-safe base64.\n * Replaces + with - and / with _ by default.\n */\nexport function urlsafeBase64Encode(\n data: string | Buffer,\n unsafeCharsMapping: Record<string, string> = { \"+\": \"-\", \"/\": \"_\" },\n): string {\n return Buffer.from(data)\n .toString(\"base64\")\n .replace(/[+/]/g, (m) => unsafeCharsMapping[m]);\n}\n\n/**\n * Decode URL-safe base64 data.\n * Replaces - with + and _ with / by default.\n */\nexport function urlsafeBase64Decode(\n encoded: string,\n unsafeCharsMapping: Record<string, string> = { \"-\": \"+\", _: \"/\" },\n): Buffer {\n return Buffer.from(\n encoded.replace(/[-_]/g, (m) => unsafeCharsMapping[m]),\n \"base64\",\n );\n}\n\n/**\n * Derive an HMAC signing key using the itsdangerous-compatible format.\n */\nfunction deriveSigningKey(\n secretKey: string,\n salt: string,\n algorithm: string,\n): Buffer {\n return createHash(algorithm).update(`${salt}signer${secretKey}`).digest();\n}\n\n/**\n * Convert snake_case keys to camelCase.\n */\nfunction keysToCamel<T extends Record<string, unknown>>(\n obj: T,\n): Record<string, unknown> {\n const newObj: Record<string, unknown> = {};\n Object.keys(obj).forEach((key) => {\n const camelKey = key.replace(/(_[a-z])/gi, (s) =>\n s.toUpperCase().replace(\"_\", \"\"),\n );\n newObj[camelKey] = obj[key];\n });\n return newObj;\n}\n\nexport interface SerializeOptions {\n /** Salt for key derivation (default: \"itsdangerous\") */\n salt?: string;\n /** Hash algorithm for HMAC (default: \"sha1\") */\n algorithm?: string;\n}\n\n/**\n * Serialize an object to a URL-safe segment with optional compression and HMAC signature.\n *\n * The format is compatible with Python's itsdangerous library.\n * - If compression saves space, the payload is prefixed with \".\"\n * - The signature is appended after a \".\" separator\n */\nexport async function serialize(\n obj: object,\n secretKey: string,\n options: SerializeOptions = {},\n): Promise<string> {\n const { salt = \"itsdangerous\", algorithm = \"sha1\" } = options;\n\n let json = Buffer.from(JSON.stringify(obj));\n const compressed = await zip(json);\n\n let isCompressed = false;\n if (compressed.length < json.length - 1) {\n json = compressed;\n isCompressed = true;\n }\n\n let encoded = urlsafeBase64Encode(json);\n if (isCompressed) {\n encoded = `.${encoded}`;\n }\n\n const derivedKey = deriveSigningKey(secretKey, salt, algorithm);\n const hmac = createHmac(algorithm, derivedKey).update(encoded).digest();\n return `${encoded}.${urlsafeBase64Encode(hmac)}`;\n}\n\nexport interface DeserializeOptions extends SerializeOptions {\n /** Whether to convert snake_case keys to camelCase (default: true) */\n convertKeysToCamel?: boolean;\n}\n\n/**\n * Deserialize a URL segment, verify its signature, and decompress if needed.\n *\n * Throws an error if the signature is invalid.\n */\nexport async function deserialize<T = Record<string, unknown>>(\n segment: string,\n secretKey: string,\n options: DeserializeOptions = {},\n): Promise<T> {\n const {\n salt = \"itsdangerous\",\n algorithm = \"sha1\",\n convertKeysToCamel = true,\n } = options;\n\n const parts = segment.split(\".\");\n const signature = parts.at(-1);\n let payload = parts.slice(0, -1).join(\".\");\n\n const derivedKey = deriveSigningKey(secretKey, salt, algorithm);\n const hmac = createHmac(algorithm, derivedKey).update(payload).digest();\n if (Buffer.compare(urlsafeBase64Decode(signature!), hmac) !== 0) {\n throw new Error(\"invalid url segment signature\");\n }\n\n let decompress = false;\n if (payload.startsWith(\".\")) {\n decompress = true;\n payload = payload.slice(1);\n }\n\n const decoded = urlsafeBase64Decode(payload);\n let parsed: Record<string, unknown>;\n\n if (decompress) {\n const decompressed = await unzip(decoded);\n parsed = JSON.parse(decompressed.toString()) as Record<string, unknown>;\n } else {\n parsed = JSON.parse(decoded.toString()) as Record<string, unknown>;\n }\n\n if (convertKeysToCamel) {\n return keysToCamel(parsed) as T;\n }\n return parsed as T;\n}\n"],"mappings":";AAKA,OAAO,UAAU;AACjB,SAAS,iBAAiB;AAC1B,SAAS,YAAY,kBAAkB;AAEvC,IAAM,MAAM,UAAU,KAAK,IAAI;AAC/B,IAAM,QAAQ,UAAU,KAAK,KAAK;AAM3B,SAAS,oBACd,MACA,qBAA6C,EAAE,KAAK,KAAK,KAAK,IAAI,GAC1D;AACR,SAAO,OAAO,KAAK,IAAI,EACpB,SAAS,QAAQ,EACjB,QAAQ,SAAS,CAAC,MAAM,mBAAmB,CAAC,CAAC;AAClD;AAMO,SAAS,oBACd,SACA,qBAA6C,EAAE,KAAK,KAAK,GAAG,IAAI,GACxD;AACR,SAAO,OAAO;AAAA,IACZ,QAAQ,QAAQ,SAAS,CAAC,MAAM,mBAAmB,CAAC,CAAC;AAAA,IACrD;AAAA,EACF;AACF;AAKA,SAAS,iBACP,WACA,MACA,WACQ;AACR,SAAO,WAAW,SAAS,EAAE,OAAO,GAAG,IAAI,SAAS,SAAS,EAAE,EAAE,OAAO;AAC1E;AAKA,SAAS,YACP,KACyB;AACzB,QAAM,SAAkC,CAAC;AACzC,SAAO,KAAK,GAAG,EAAE,QAAQ,CAAC,QAAQ;AAChC,UAAM,WAAW,IAAI;AAAA,MAAQ;AAAA,MAAc,CAAC,MAC1C,EAAE,YAAY,EAAE,QAAQ,KAAK,EAAE;AAAA,IACjC;AACA,WAAO,QAAQ,IAAI,IAAI,GAAG;AAAA,EAC5B,CAAC;AACD,SAAO;AACT;AAgBA,eAAsB,UACpB,KACA,WACA,UAA4B,CAAC,GACZ;AACjB,QAAM,EAAE,OAAO,gBAAgB,YAAY,OAAO,IAAI;AAEtD,MAAI,OAAO,OAAO,KAAK,KAAK,UAAU,GAAG,CAAC;AAC1C,QAAM,aAAa,MAAM,IAAI,IAAI;AAEjC,MAAI,eAAe;AACnB,MAAI,WAAW,SAAS,KAAK,SAAS,GAAG;AACvC,WAAO;AACP,mBAAe;AAAA,EACjB;AAEA,MAAI,UAAU,oBAAoB,IAAI;AACtC,MAAI,cAAc;AAChB,cAAU,IAAI,OAAO;AAAA,EACvB;AAEA,QAAM,aAAa,iBAAiB,WAAW,MAAM,SAAS;AAC9D,QAAM,OAAO,WAAW,WAAW,UAAU,EAAE,OAAO,OAAO,EAAE,OAAO;AACtE,SAAO,GAAG,OAAO,IAAI,oBAAoB,IAAI,CAAC;AAChD;AAYA,eAAsB,YACpB,SACA,WACA,UAA8B,CAAC,GACnB;AACZ,QAAM;AAAA,IACJ,OAAO;AAAA,IACP,YAAY;AAAA,IACZ,qBAAqB;AAAA,EACvB,IAAI;AAEJ,QAAM,QAAQ,QAAQ,MAAM,GAAG;AAC/B,QAAM,YAAY,MAAM,GAAG,EAAE;AAC7B,MAAI,UAAU,MAAM,MAAM,GAAG,EAAE,EAAE,KAAK,GAAG;AAEzC,QAAM,aAAa,iBAAiB,WAAW,MAAM,SAAS;AAC9D,QAAM,OAAO,WAAW,WAAW,UAAU,EAAE,OAAO,OAAO,EAAE,OAAO;AACtE,MAAI,OAAO,QAAQ,oBAAoB,SAAU,GAAG,IAAI,MAAM,GAAG;AAC/D,UAAM,IAAI,MAAM,+BAA+B;AAAA,EACjD;AAEA,MAAI,aAAa;AACjB,MAAI,QAAQ,WAAW,GAAG,GAAG;AAC3B,iBAAa;AACb,cAAU,QAAQ,MAAM,CAAC;AAAA,EAC3B;AAEA,QAAM,UAAU,oBAAoB,OAAO;AAC3C,MAAI;AAEJ,MAAI,YAAY;AACd,UAAM,eAAe,MAAM,MAAM,OAAO;AACxC,aAAS,KAAK,MAAM,aAAa,SAAS,CAAC;AAAA,EAC7C,OAAO;AACL,aAAS,KAAK,MAAM,QAAQ,SAAS,CAAC;AAAA,EACxC;AAEA,MAAI,oBAAoB;AACtB,WAAO,YAAY,MAAM;AAAA,EAC3B;AACA,SAAO;AACT;","names":[]}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@effing/serde",
3
+ "version": "0.1.0",
4
+ "description": "URL-safe serialization with compression and HMAC signing",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "devDependencies": {
16
+ "tsup": "^8.0.0",
17
+ "typescript": "^5.9.3",
18
+ "vitest": "^3.2.4"
19
+ },
20
+ "keywords": [
21
+ "serialization",
22
+ "base64",
23
+ "hmac",
24
+ "url-safe"
25
+ ],
26
+ "license": "O'Saasy",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run"
34
+ }
35
+ }