@automate.ax/codec 0.1.3

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/dist/walk.js ADDED
@@ -0,0 +1,69 @@
1
+ import { BufferedBlob, BufferedFile, BufferedRequest, BufferedResponse, } from "./buffered.js";
2
+ import { isPlainObject } from "./lib/utils.js";
3
+ /**
4
+ * Walks a value tree and replaces anything with an async body (`Request`,
5
+ * `Response`, `Blob`, `File`) with a sync-buffered snapshot, so the result can
6
+ * be handed to a synchronous encoder.
7
+ *
8
+ * Recurses through plain objects, arrays, `Map`s, and `Set`s. Leaves
9
+ * primitives, typed arrays, and sync class instances (`Headers`, `URL`, etc.)
10
+ * alone — those either have no async surface or msgpackr handles them natively
11
+ * via registered extensions at pack time.
12
+ *
13
+ * Dedupes by reference within a single call so that a tree sharing the same
14
+ * `Request`/`Response`/`Blob`/`File` in multiple places doesn't trigger "body
15
+ * already used".
16
+ *
17
+ * Does not detect cycles. Automation outputs are expected to be tree-shaped.
18
+ *
19
+ * @param value - Value tree to prepare for synchronous encoding.
20
+ */
21
+ export async function bufferAsyncValues(value) {
22
+ return walk(value, new WeakMap());
23
+ }
24
+ /**
25
+ * Walks a value while reusing in-flight work for repeated object references.
26
+ *
27
+ * @param value - Value at the current traversal position.
28
+ * @param cache - Buffered results keyed by source object identity.
29
+ */
30
+ async function walk(value, cache) {
31
+ if (value === null || typeof value !== "object")
32
+ return value;
33
+ const cached = cache.get(value);
34
+ if (cached)
35
+ return cached;
36
+ const task = walkFresh(value, cache);
37
+ cache.set(value, task);
38
+ return task;
39
+ }
40
+ /**
41
+ * Buffers an object that does not yet have a cached traversal task.
42
+ *
43
+ * @param value - Object to inspect and buffer.
44
+ * @param cache - Buffered results keyed by source object identity.
45
+ */
46
+ async function walkFresh(value, cache) {
47
+ if (value instanceof Request)
48
+ return BufferedRequest.from(value);
49
+ if (value instanceof Response)
50
+ return BufferedResponse.from(value);
51
+ // File extends Blob — must be checked first so we don't lose name/lastModified.
52
+ if (value instanceof File)
53
+ return BufferedFile.from(value);
54
+ if (value instanceof Blob)
55
+ return BufferedBlob.from(value);
56
+ if (Array.isArray(value)) {
57
+ return Promise.all(value.map((v) => walk(v, cache)));
58
+ }
59
+ if (value instanceof Map) {
60
+ return new Map(await Promise.all([...value.entries()].map(async ([k, v]) => [await walk(k, cache), await walk(v, cache)])));
61
+ }
62
+ if (value instanceof Set) {
63
+ return new Set(await Promise.all([...value].map((v) => walk(v, cache))));
64
+ }
65
+ if (isPlainObject(value)) {
66
+ return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await walk(v, cache)])));
67
+ }
68
+ return value;
69
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@automate.ax/codec",
3
+ "version": "0.1.3",
4
+ "description": "Msgpack-based encoding helpers for Automate.ax runtime data.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/zachsents/automate.ax.git",
9
+ "directory": "packages/codec"
10
+ },
11
+ "homepage": "https://automate.ax",
12
+ "bugs": {
13
+ "url": "https://github.com/zachsents/automate.ax/issues"
14
+ },
15
+ "type": "module",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "zshy": {
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./*": "./src/*"
23
+ },
24
+ "cjs": false,
25
+ "conditions": {
26
+ "bun": "src"
27
+ }
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "bun": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./*": {
36
+ "bun": "./src/*",
37
+ "types": "./dist/*",
38
+ "default": "./dist/*"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "oxlint --type-aware",
44
+ "lint:fix": "oxlint --type-aware --fix-suggestions",
45
+ "check": "bun run typecheck && bun run lint",
46
+ "test": "vitest run",
47
+ "build": "zshy",
48
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "README.md",
54
+ "LICENSE"
55
+ ],
56
+ "main": "./dist/index.js",
57
+ "module": "./dist/index.js",
58
+ "types": "./dist/index.d.ts",
59
+ "devDependencies": {
60
+ "@internal/config": "0.0.0",
61
+ "@types/bun": "latest",
62
+ "vitest": "^4.1.9",
63
+ "zshy": "^0.7.2"
64
+ },
65
+ "dependencies": {
66
+ "@standard-schema/spec": "^1.1.0",
67
+ "msgpackr": "^1.11.9",
68
+ "zod": "^4.3.6"
69
+ }
70
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Sync snapshots of async-bodied web types (`Request`, `Response`, `Blob`,
3
+ * `File`) after their bodies have been drained. msgpackr's extension `write`
4
+ * callback is synchronous, so anything with an async body must be pre-processed
5
+ * into one of these before packing.
6
+ */
7
+
8
+ /**
9
+ * `Uint8Array<ArrayBuffer>` — the shape `BodyInit`/`BlobPart` expect under DOM
10
+ * lib.
11
+ */
12
+ type Bytes = Uint8Array<ArrayBuffer>
13
+
14
+ const METHODS_WITHOUT_BODY = new Set(["GET", "HEAD"])
15
+
16
+ /**
17
+ * Wraps an `ArrayBuffer` in the byte-array shape accepted by web body APIs.
18
+ *
19
+ * @param buffer - Buffer to expose as bytes.
20
+ */
21
+ function toBytes(buffer: ArrayBuffer): Bytes {
22
+ return new Uint8Array(buffer)
23
+ }
24
+
25
+ /** A serializable snapshot of a request and its buffered body. */
26
+ export class BufferedRequest {
27
+ /**
28
+ * Creates a buffered request snapshot.
29
+ *
30
+ * @param url - Original request URL.
31
+ * @param method - Original HTTP method.
32
+ * @param headers - Original headers as string entries.
33
+ * @param body - Buffered body, or `null` when the request has no body.
34
+ */
35
+ constructor(
36
+ public readonly url: string,
37
+ public readonly method: string,
38
+ public readonly headers: Record<string, string>,
39
+ public readonly body: Bytes | null,
40
+ ) {}
41
+
42
+ /**
43
+ * Drains a request into a serializable snapshot.
44
+ *
45
+ * @param req - Request to buffer.
46
+ */
47
+ static async from(req: Request): Promise<BufferedRequest> {
48
+ return new BufferedRequest(
49
+ req.url,
50
+ req.method,
51
+ Object.fromEntries(req.headers),
52
+ req.body !== null && !METHODS_WITHOUT_BODY.has(req.method)
53
+ ? toBytes(await req.arrayBuffer())
54
+ : null,
55
+ )
56
+ }
57
+
58
+ /** Reconstructs a request from this snapshot. */
59
+ toRequest(): Request {
60
+ return new Request(this.url, {
61
+ method: this.method,
62
+ headers: new Headers(this.headers),
63
+ body:
64
+ this.body !== null && !METHODS_WITHOUT_BODY.has(this.method)
65
+ ? this.body
66
+ : undefined,
67
+ })
68
+ }
69
+ }
70
+
71
+ /** A serializable snapshot of a response and its buffered body. */
72
+ export class BufferedResponse {
73
+ /**
74
+ * Creates a buffered response snapshot.
75
+ *
76
+ * @param status - Original response status code.
77
+ * @param statusText - Original response status text.
78
+ * @param headers - Original headers as string entries.
79
+ * @param body - Buffered body, or `null` when the response has no body.
80
+ */
81
+ constructor(
82
+ public readonly status: number,
83
+ public readonly statusText: string,
84
+ public readonly headers: Record<string, string>,
85
+ public readonly body: Bytes | null,
86
+ ) {}
87
+
88
+ /**
89
+ * Drains a response into a serializable snapshot.
90
+ *
91
+ * @param res - Response to buffer.
92
+ */
93
+ static async from(res: Response): Promise<BufferedResponse> {
94
+ return new BufferedResponse(
95
+ res.status,
96
+ res.statusText,
97
+ Object.fromEntries(res.headers),
98
+ res.body !== null ? toBytes(await res.arrayBuffer()) : null,
99
+ )
100
+ }
101
+
102
+ /** Reconstructs a response from this snapshot. */
103
+ toResponse(): Response {
104
+ return new Response(this.body, {
105
+ status: this.status,
106
+ statusText: this.statusText,
107
+ headers: new Headers(this.headers),
108
+ })
109
+ }
110
+ }
111
+
112
+ /** A serializable snapshot of a blob and its buffered bytes. */
113
+ export class BufferedBlob {
114
+ /**
115
+ * Creates a buffered blob snapshot.
116
+ *
117
+ * @param type - Original media type.
118
+ * @param bytes - Buffered blob contents.
119
+ */
120
+ constructor(
121
+ public readonly type: string,
122
+ public readonly bytes: Bytes,
123
+ ) {}
124
+
125
+ /**
126
+ * Drains a blob into a serializable snapshot.
127
+ *
128
+ * @param blob - Blob to buffer.
129
+ */
130
+ static async from(blob: Blob): Promise<BufferedBlob> {
131
+ return new BufferedBlob(blob.type, toBytes(await blob.arrayBuffer()))
132
+ }
133
+
134
+ /** Reconstructs a blob from this snapshot. */
135
+ toBlob(): Blob {
136
+ return new Blob([this.bytes], { type: this.type })
137
+ }
138
+ }
139
+
140
+ /** A serializable snapshot of a file and its buffered bytes. */
141
+ export class BufferedFile {
142
+ /**
143
+ * Creates a buffered file snapshot.
144
+ *
145
+ * @param name - Original file name.
146
+ * @param type - Original media type.
147
+ * @param lastModified - Original modification timestamp.
148
+ * @param bytes - Buffered file contents.
149
+ */
150
+ constructor(
151
+ public readonly name: string,
152
+ public readonly type: string,
153
+ public readonly lastModified: number,
154
+ public readonly bytes: Bytes,
155
+ ) {}
156
+
157
+ /**
158
+ * Drains a file into a serializable snapshot.
159
+ *
160
+ * @param file - File to buffer.
161
+ */
162
+ static async from(file: File): Promise<BufferedFile> {
163
+ return new BufferedFile(
164
+ file.name,
165
+ file.type,
166
+ file.lastModified,
167
+ toBytes(await file.arrayBuffer()),
168
+ )
169
+ }
170
+
171
+ /** Reconstructs a file from this snapshot. */
172
+ toFile(): File {
173
+ return new File([this.bytes], this.name, {
174
+ type: this.type,
175
+ lastModified: this.lastModified,
176
+ })
177
+ }
178
+ }
@@ -0,0 +1,93 @@
1
+ import { addExtension } from "msgpackr"
2
+ import {
3
+ BufferedBlob,
4
+ BufferedFile,
5
+ BufferedRequest,
6
+ BufferedResponse,
7
+ } from "./buffered"
8
+ import { EXT_CODES } from "./type-codes"
9
+
10
+ addExtension({
11
+ Class: BufferedRequest,
12
+ type: EXT_CODES.REQUEST,
13
+ write(instance: BufferedRequest) {
14
+ return [instance.url, instance.method, instance.headers, instance.body]
15
+ },
16
+ read([url, method, headers, body]: [
17
+ string,
18
+ string,
19
+ Record<string, string>,
20
+ Uint8Array<ArrayBuffer> | null,
21
+ ]) {
22
+ return new BufferedRequest(url, method, headers, body).toRequest()
23
+ },
24
+ })
25
+
26
+ addExtension({
27
+ Class: BufferedResponse,
28
+ type: EXT_CODES.RESPONSE,
29
+ write(instance: BufferedResponse) {
30
+ return [
31
+ instance.status,
32
+ instance.statusText,
33
+ instance.headers,
34
+ instance.body,
35
+ ]
36
+ },
37
+ read([status, statusText, headers, body]: [
38
+ number,
39
+ string,
40
+ Record<string, string>,
41
+ Uint8Array<ArrayBuffer> | null,
42
+ ]) {
43
+ return new BufferedResponse(status, statusText, headers, body).toResponse()
44
+ },
45
+ })
46
+
47
+ addExtension({
48
+ Class: BufferedBlob,
49
+ type: EXT_CODES.BLOB,
50
+ write(instance: BufferedBlob) {
51
+ return [instance.type, instance.bytes]
52
+ },
53
+ read([type, bytes]: [string, Uint8Array<ArrayBuffer>]) {
54
+ return new BufferedBlob(type, bytes).toBlob()
55
+ },
56
+ })
57
+
58
+ addExtension({
59
+ Class: BufferedFile,
60
+ type: EXT_CODES.FILE,
61
+ write(instance: BufferedFile) {
62
+ return [instance.name, instance.type, instance.lastModified, instance.bytes]
63
+ },
64
+ read([name, type, lastModified, bytes]: [
65
+ string,
66
+ string,
67
+ number,
68
+ Uint8Array<ArrayBuffer>,
69
+ ]) {
70
+ return new BufferedFile(name, type, lastModified, bytes).toFile()
71
+ },
72
+ })
73
+
74
+ addExtension({
75
+ Class: Headers,
76
+ type: EXT_CODES.HEADERS,
77
+ write: (h: Headers) => Object.fromEntries(h),
78
+ read: (entries: Record<string, string>) => new Headers(entries),
79
+ })
80
+
81
+ addExtension({
82
+ Class: URL,
83
+ type: EXT_CODES.URL,
84
+ write: (u: URL) => u.href,
85
+ read: (href: string) => new URL(href),
86
+ })
87
+
88
+ addExtension({
89
+ Class: URLSearchParams,
90
+ type: EXT_CODES.URL_SEARCH_PARAMS,
91
+ write: (p: URLSearchParams) => [...p.entries()],
92
+ read: (entries: [string, string][]) => new URLSearchParams(entries),
93
+ })
package/src/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ import z from "zod"
2
+ import { isPlainObject } from "./lib/utils"
3
+ import { packr } from "./packr"
4
+ import { bufferAsyncValues } from "./walk"
5
+
6
+ export type { ObjectProducingSchema, ProducingSchema } from "./schema"
7
+
8
+ /**
9
+ * Values accepted by the codec encoder, including recursively nested containers
10
+ * and registered web-platform extension types.
11
+ */
12
+ export type Encodable =
13
+ // primitives
14
+ | null
15
+ | undefined
16
+ | void
17
+ | boolean
18
+ | number
19
+ | bigint
20
+ | string
21
+ // typed arrays
22
+ | Uint8Array
23
+ | Uint8ClampedArray
24
+ | Int8Array
25
+ | Uint16Array
26
+ | Int16Array
27
+ | Uint32Array
28
+ | Int32Array
29
+ | Float32Array
30
+ | Float64Array
31
+ | BigUint64Array
32
+ | BigInt64Array
33
+ // other types
34
+ | Encodable[]
35
+ | readonly Encodable[]
36
+ | { readonly [key: string]: Encodable }
37
+ | Map<Encodable, Encodable>
38
+ | Set<Encodable>
39
+ | Date
40
+ | RegExp
41
+ | ArrayBuffer
42
+ | DataView
43
+ | Request
44
+ | Response
45
+ | Blob
46
+ | File
47
+ | Headers
48
+ | URL
49
+ | URLSearchParams
50
+
51
+ /**
52
+ * Asserts that a value belongs to the codec's supported input surface.
53
+ *
54
+ * @param value - Value to validate.
55
+ * @throws {TypeError} When the value cannot be encoded.
56
+ */
57
+ function assertEncodable(value: unknown): asserts value is Encodable {
58
+ if (!isEncodable(value)) {
59
+ throw new TypeError(
60
+ `Value is not encodable: ${Object.prototype.toString.call(value)}`,
61
+ )
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Checks whether an unknown value fits the codec's supported input surface.
67
+ *
68
+ * @param value - Value to inspect.
69
+ */
70
+ export function isEncodable(value: unknown): value is Encodable {
71
+ const activePath = new WeakSet<object>()
72
+
73
+ /**
74
+ * Validates one value while tracking the active traversal path for cycles.
75
+ *
76
+ * @param value - Value at the current traversal position.
77
+ */
78
+ function visit(value: unknown): value is Encodable {
79
+ if (value == null) return true
80
+
81
+ switch (typeof value) {
82
+ case "boolean":
83
+ case "bigint":
84
+ case "number":
85
+ case "string":
86
+ return true
87
+ case "function":
88
+ case "symbol":
89
+ return false
90
+ case "object":
91
+ break
92
+ default:
93
+ return false
94
+ }
95
+
96
+ if (
97
+ value instanceof Date ||
98
+ value instanceof RegExp ||
99
+ value instanceof ArrayBuffer ||
100
+ ArrayBuffer.isView(value) ||
101
+ value instanceof Request ||
102
+ value instanceof Response ||
103
+ value instanceof Blob ||
104
+ value instanceof File ||
105
+ value instanceof Headers ||
106
+ value instanceof URL ||
107
+ value instanceof URLSearchParams
108
+ ) {
109
+ return true
110
+ }
111
+
112
+ if (activePath.has(value)) return false
113
+
114
+ activePath.add(value)
115
+ try {
116
+ return Array.isArray(value)
117
+ ? value.every(visit)
118
+ : value instanceof Map
119
+ ? [...value].every(([key, item]) => visit(key) && visit(item))
120
+ : value instanceof Set
121
+ ? [...value].every(visit)
122
+ : isPlainObject(value) && Object.values(value).every(visit)
123
+ } finally {
124
+ activePath.delete(value)
125
+ }
126
+ }
127
+
128
+ return visit(value)
129
+ }
130
+
131
+ /** Zod schema for values accepted by the codec encoder. */
132
+ export const encodableSchema = z.custom<Encodable>(isEncodable, {
133
+ message: "Value is not encodable",
134
+ })
135
+
136
+ /**
137
+ * Serializes a value after checking it against the codec's supported input
138
+ * surface.
139
+ *
140
+ * Pre-walks the value to buffer any async-bodied values (`Request`, `Response`)
141
+ * before handing off to the synchronous packer.
142
+ *
143
+ * @param value - Value to validate and serialize.
144
+ * @throws {TypeError} When the value is outside the supported input surface.
145
+ */
146
+ export async function encode(value: unknown): Promise<Uint8Array> {
147
+ assertEncodable(value)
148
+ return packr.pack(await bufferAsyncValues(value))
149
+ }
150
+
151
+ /**
152
+ * Deserializes msgpack bytes.
153
+ *
154
+ * Currently synchronous under the hood, but typed as async to leave room for
155
+ * future concerns that need await (e.g. resolving blob refs from external
156
+ * storage, streaming decodes).
157
+ *
158
+ * Returns the codec's supported value surface. Callers should still validate
159
+ * the domain-specific shape where they know the expected type.
160
+ *
161
+ * @param bytes - Msgpack bytes to deserialize.
162
+ * @throws {TypeError} When the decoded payload is outside the supported
163
+ * surface.
164
+ */
165
+ export async function decode(bytes: Uint8Array): Promise<Encodable> {
166
+ const value = packr.unpack(bytes)
167
+ assertEncodable(value)
168
+ return value
169
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Checks whether an object has the standard object prototype or no prototype.
3
+ *
4
+ * @param value - Object to inspect.
5
+ */
6
+ export function isPlainObject(value: object): value is Record<string, unknown> {
7
+ const proto = Object.getPrototypeOf(value)
8
+ return proto === Object.prototype || proto === null
9
+ }
package/src/packr.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Packr } from "msgpackr"
2
+ import "./extensions"
3
+
4
+ /**
5
+ * Single configured `Packr` instance used for all encode/decode in the runtime.
6
+ *
7
+ * Extensions are registered globally via `addExtension` (see `./extensions`),
8
+ * so any `Packr` instance picks them up — but using one shared instance keeps
9
+ * option configuration consistent.
10
+ */
11
+ export const packr = new Packr({
12
+ useRecords: true,
13
+ mapsAsObjects: false,
14
+ moreTypes: true,
15
+ int64AsType: "bigint",
16
+ useBigIntExtension: true,
17
+ encodeUndefinedAsNil: false,
18
+ })
package/src/schema.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
2
+
3
+ /** Standard Schema whose validated output is assignable to the given type. */
4
+ export type ProducingSchema<TOutput> = StandardSchemaV1<
5
+ // oxlint-disable-next-line typescript/no-explicit-any -- schema input variance should not constrain output type
6
+ any,
7
+ TOutput
8
+ >
9
+
10
+ /** Standard Schema whose validated output is a string-keyed object. */
11
+ export type ObjectProducingSchema<TValue> = ProducingSchema<
12
+ Record<string, TValue>
13
+ >
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stable msgpack extension type codes for built-in runtime value types.
3
+ *
4
+ * These numbers are part of the wire format. Changing an existing code is a
5
+ * breaking change that would invalidate all previously stored outputs. Treat
6
+ * this file as append-only.
7
+ *
8
+ * Msgpackr reserves negative codes for MessagePack itself and 101-127 for its
9
+ * own use. Codes 1-100 are available to built-in application values.
10
+ */
11
+ export const EXT_CODES = {
12
+ REQUEST: 1,
13
+ RESPONSE: 2,
14
+ BLOB: 3,
15
+ FILE: 4,
16
+ HEADERS: 5,
17
+ URL: 6,
18
+ URL_SEARCH_PARAMS: 7,
19
+ } as const