From b79df9630106068f77cce91c420f2d0758575926 Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:26:42 +0800 Subject: [PATCH] feat(struct): allow structs to be used as fields directly (#741) --- .changeset/some-tigers-hide.md | 5 + libraries/adb-daemon-webusb/src/device.ts | 26 +- libraries/adb/src/commands/reverse.ts | 9 +- libraries/adb/src/commands/sync/list.ts | 24 +- libraries/adb/src/commands/sync/response.ts | 6 +- libraries/adb/src/daemon/packet.ts | 12 +- .../scrcpy/src/1_15/impl/inject-touch.ts | 21 +- .../scrcpy/src/1_18/impl/back-or-screen-on.ts | 14 +- .../scrcpy/src/1_22/impl/scroll-controller.ts | 14 +- .../src/1_25/impl/scroll-controller.spec.ts | 17 +- .../scrcpy/src/1_25/impl/scroll-controller.ts | 15 +- .../stream-extra/src/struct-deserialize.ts | 4 +- .../stream-extra/src/struct-serialize.ts | 4 +- libraries/struct/src/bipedal.ts | 26 +- libraries/struct/src/buffer.ts | 400 +++++++++++------- libraries/struct/src/concat.ts | 78 ++++ libraries/struct/src/extend.ts | 37 ++ libraries/struct/src/field.ts | 27 -- libraries/struct/src/field/factory.ts | 64 +++ libraries/struct/src/field/index.ts | 3 + libraries/struct/src/field/serialize.ts | 58 +++ libraries/struct/src/field/types.ts | 48 +++ libraries/struct/src/index.ts | 5 +- libraries/struct/src/number.spec.ts | 71 ++++ libraries/struct/src/number.ts | 79 ++-- libraries/struct/src/readable.ts | 28 ++ libraries/struct/src/string.ts | 2 +- libraries/struct/src/struct.spec.ts | 23 +- libraries/struct/src/struct.ts | 213 ++++++---- libraries/struct/src/types.ts | 37 ++ toolchain/side-effect-test/package.json | 3 +- toolchain/side-effect-test/src/index.js | 12 +- 32 files changed, 956 insertions(+), 429 deletions(-) create mode 100644 .changeset/some-tigers-hide.md create mode 100644 libraries/struct/src/concat.ts create mode 100644 libraries/struct/src/extend.ts delete mode 100644 libraries/struct/src/field.ts create mode 100644 libraries/struct/src/field/factory.ts create mode 100644 libraries/struct/src/field/index.ts create mode 100644 libraries/struct/src/field/serialize.ts create mode 100644 libraries/struct/src/field/types.ts create mode 100644 libraries/struct/src/number.spec.ts create mode 100644 libraries/struct/src/types.ts diff --git a/.changeset/some-tigers-hide.md b/.changeset/some-tigers-hide.md new file mode 100644 index 00000000..07bb3863 --- /dev/null +++ b/.changeset/some-tigers-hide.md @@ -0,0 +1,5 @@ +--- +"@yume-chan/struct": major +--- + +Refactor struct package to allow `struct`s to be used as `field` diff --git a/libraries/adb-daemon-webusb/src/device.ts b/libraries/adb-daemon-webusb/src/device.ts index fcf9545e..f4571731 100644 --- a/libraries/adb-daemon-webusb/src/device.ts +++ b/libraries/adb-daemon-webusb/src/device.ts @@ -19,8 +19,7 @@ import { ReadableStream, pipeFrom, } from "@yume-chan/stream-extra"; -import type { ExactReadable } from "@yume-chan/struct"; -import { EmptyUint8Array } from "@yume-chan/struct"; +import { EmptyUint8Array, Uint8ArrayExactReadable } from "@yume-chan/struct"; import { DeviceBusyError as _DeviceBusyError } from "./error.js"; import type { UsbInterfaceFilter, UsbInterfaceIdentifier } from "./utils.js"; @@ -52,29 +51,6 @@ export function mergeDefaultAdbInterfaceFilter( } } -class Uint8ArrayExactReadable implements ExactReadable { - #data: Uint8Array; - #position: number; - - get position() { - return this.#position; - } - - constructor(data: Uint8Array) { - this.#data = data; - this.#position = 0; - } - - readExactly(length: number): Uint8Array { - const result = this.#data.subarray( - this.#position, - this.#position + length, - ); - this.#position += length; - return result; - } -} - export class AdbDaemonWebUsbConnection implements ReadableWritablePair> { diff --git a/libraries/adb/src/commands/reverse.ts b/libraries/adb/src/commands/reverse.ts index 64f0c243..f3c1a8f9 100644 --- a/libraries/adb/src/commands/reverse.ts +++ b/libraries/adb/src/commands/reverse.ts @@ -4,6 +4,7 @@ import { BufferedReadableStream } from "@yume-chan/stream-extra"; import { encodeUtf8, ExactReadableEndedError, + extend, string, struct, } from "@yume-chan/struct"; @@ -49,11 +50,11 @@ export class AdbReverseNotSupportedError extends AdbReverseError { } } -const AdbReverseErrorResponse = struct( - /* #__PURE__ */ (() => AdbReverseStringResponse.fields)(), +const AdbReverseErrorResponse = extend( + AdbReverseStringResponse, + {}, { - littleEndian: true, - postDeserialize: (value) => { + postDeserialize(value) { // https://issuetracker.google.com/issues/37066218 // ADB on Android <9 can't create reverse tunnels when connected wirelessly (ADB over Wi-Fi), // and returns this confusing "more than one device/emulator" error. diff --git a/libraries/adb/src/commands/sync/list.ts b/libraries/adb/src/commands/sync/list.ts index f3ee29ec..531cdcb6 100644 --- a/libraries/adb/src/commands/sync/list.ts +++ b/libraries/adb/src/commands/sync/list.ts @@ -1,5 +1,5 @@ import type { StructValue } from "@yume-chan/struct"; -import { string, struct, u32 } from "@yume-chan/struct"; +import { extend, string, u32 } from "@yume-chan/struct"; import { AdbSyncRequestId, adbSyncWriteRequest } from "./request.js"; import { AdbSyncResponseId, adbSyncReadResponses } from "./response.js"; @@ -15,25 +15,15 @@ export interface AdbSyncEntry extends AdbSyncStat { name: string; } -export const AdbSyncEntryResponse = /* #__PURE__ */ (() => - struct( - { - ...AdbSyncLstatResponse.fields, - name: string(u32), - }, - { littleEndian: true, extra: AdbSyncLstatResponse.extra }, - ))(); +export const AdbSyncEntryResponse = extend(AdbSyncLstatResponse, { + name: string(u32), +}); export type AdbSyncEntryResponse = StructValue; -export const AdbSyncEntry2Response = /* #__PURE__ */ (() => - struct( - { - ...AdbSyncStatResponse.fields, - name: string(u32), - }, - { littleEndian: true, extra: AdbSyncStatResponse.extra }, - ))(); +export const AdbSyncEntry2Response = extend(AdbSyncStatResponse, { + name: string(u32), +}); export type AdbSyncEntry2Response = StructValue; diff --git a/libraries/adb/src/commands/sync/response.ts b/libraries/adb/src/commands/sync/response.ts index c2566e5b..1bad8d60 100644 --- a/libraries/adb/src/commands/sync/response.ts +++ b/libraries/adb/src/commands/sync/response.ts @@ -1,5 +1,5 @@ import { getUint32LittleEndian } from "@yume-chan/no-data-view"; -import type { AsyncExactReadable, StructLike } from "@yume-chan/struct"; +import type { AsyncExactReadable, StructDeserializer } from "@yume-chan/struct"; import { decodeUtf8, string, struct, u32 } from "@yume-chan/struct"; function encodeAsciiUnchecked(value: string): Uint8Array { @@ -49,7 +49,7 @@ export const AdbSyncFailResponse = struct( export async function adbSyncReadResponse( stream: AsyncExactReadable, id: number | string, - type: StructLike, + type: StructDeserializer, ): Promise { if (typeof id === "string") { id = adbSyncEncodeId(id); @@ -72,7 +72,7 @@ export async function adbSyncReadResponse( export async function* adbSyncReadResponses( stream: AsyncExactReadable, id: number | string, - type: StructLike, + type: StructDeserializer, ): AsyncGenerator { if (typeof id === "string") { id = adbSyncEncodeId(id); diff --git a/libraries/adb/src/daemon/packet.ts b/libraries/adb/src/daemon/packet.ts index 49652a67..b9efbf7a 100644 --- a/libraries/adb/src/daemon/packet.ts +++ b/libraries/adb/src/daemon/packet.ts @@ -1,6 +1,6 @@ import { Consumable, TransformStream } from "@yume-chan/stream-extra"; import type { StructInit, StructValue } from "@yume-chan/struct"; -import { buffer, s32, struct, u32 } from "@yume-chan/struct"; +import { buffer, extend, s32, struct, u32 } from "@yume-chan/struct"; export const AdbCommand = { Auth: 0x48545541, // 'AUTH' @@ -29,13 +29,9 @@ export type AdbPacketHeader = StructValue; type AdbPacketHeaderInit = StructInit; -export const AdbPacket = struct( - /* #__PURE__ */ (() => ({ - ...AdbPacketHeader.fields, - payload: buffer("payloadLength"), - }))(), - { littleEndian: true }, -); +export const AdbPacket = extend(AdbPacketHeader, { + payload: buffer("payloadLength"), +}); export type AdbPacket = StructValue; diff --git a/libraries/scrcpy/src/1_15/impl/inject-touch.ts b/libraries/scrcpy/src/1_15/impl/inject-touch.ts index 0afaa68a..68e8f5ec 100644 --- a/libraries/scrcpy/src/1_15/impl/inject-touch.ts +++ b/libraries/scrcpy/src/1_15/impl/inject-touch.ts @@ -1,26 +1,27 @@ import { getUint16, setUint16 } from "@yume-chan/no-data-view"; import type { Field, StructInit } from "@yume-chan/struct"; -import { bipedal, struct, u16, u32, u64, u8 } from "@yume-chan/struct"; +import { field, struct, u16, u32, u64, u8 } from "@yume-chan/struct"; import type { AndroidMotionEventAction } from "../../android/index.js"; import type { ScrcpyInjectTouchControlMessage } from "../../latest.js"; import { clamp } from "../../utils/index.js"; -export const UnsignedFloat: Field = { - size: 2, - serialize(value, { buffer, index, littleEndian }) { +export const UnsignedFloat: Field = field( + 2, + "byob", + (source, { buffer, index, littleEndian }) => { // https://github.com/Genymobile/scrcpy/blob/1f138aef41de651668043b32c4effc2d4adbfc44/app/src/util/binary.h#L51 - value = clamp(value, -1, 1); - value = value === 1 ? 0xffff : value * 0x10000; - setUint16(buffer, index, value, littleEndian); + source = clamp(source, -1, 1); + source = source === 1 ? 0xffff : source * 0x10000; + setUint16(buffer, index, source, littleEndian); }, - deserialize: bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(2)); const value = getUint16(data, 0, littleEndian); // https://github.com/Genymobile/scrcpy/blob/1f138aef41de651668043b32c4effc2d4adbfc44/server/src/main/java/com/genymobile/scrcpy/Binary.java#L22 return value === 0xffff ? 1 : value / 0x10000; - }), -}; + }, +); export const PointerId = { Mouse: -1n, diff --git a/libraries/scrcpy/src/1_18/impl/back-or-screen-on.ts b/libraries/scrcpy/src/1_18/impl/back-or-screen-on.ts index ec9ff7f7..78a5689f 100644 --- a/libraries/scrcpy/src/1_18/impl/back-or-screen-on.ts +++ b/libraries/scrcpy/src/1_18/impl/back-or-screen-on.ts @@ -1,19 +1,15 @@ import type { StructInit } from "@yume-chan/struct"; -import { struct, u8 } from "@yume-chan/struct"; +import { extend, u8 } from "@yume-chan/struct"; import type { AndroidKeyEventAction } from "../../android/index.js"; import type { ScrcpyBackOrScreenOnControlMessage } from "../../latest.js"; import { PrevImpl } from "./prev.js"; -export const BackOrScreenOnControlMessage = /* #__PURE__ */ (() => - struct( - { - ...PrevImpl.BackOrScreenOnControlMessage.fields, - action: u8(), - }, - { littleEndian: false }, - ))(); +export const BackOrScreenOnControlMessage = extend( + PrevImpl.BackOrScreenOnControlMessage, + { action: u8() }, +); export type BackOrScreenOnControlMessage = StructInit< typeof BackOrScreenOnControlMessage diff --git a/libraries/scrcpy/src/1_22/impl/scroll-controller.ts b/libraries/scrcpy/src/1_22/impl/scroll-controller.ts index 67515d43..88f9dd3a 100644 --- a/libraries/scrcpy/src/1_22/impl/scroll-controller.ts +++ b/libraries/scrcpy/src/1_22/impl/scroll-controller.ts @@ -1,19 +1,15 @@ import type { StructInit } from "@yume-chan/struct"; -import { s32, struct } from "@yume-chan/struct"; +import { extend, s32 } from "@yume-chan/struct"; import type { ScrcpyScrollController } from "../../base/index.js"; import type { ScrcpyInjectScrollControlMessage } from "../../latest.js"; import { PrevImpl } from "./prev.js"; -export const InjectScrollControlMessage = /* #__PURE__ */ (() => - struct( - { - ...PrevImpl.InjectScrollControlMessage.fields, - buttons: s32, - }, - { littleEndian: false }, - ))(); +export const InjectScrollControlMessage = extend( + PrevImpl.InjectScrollControlMessage, + { buttons: s32 }, +); export type InjectScrollControlMessage = StructInit< typeof InjectScrollControlMessage diff --git a/libraries/scrcpy/src/1_25/impl/scroll-controller.spec.ts b/libraries/scrcpy/src/1_25/impl/scroll-controller.spec.ts index e5eb5faf..bc8dfc5c 100644 --- a/libraries/scrcpy/src/1_25/impl/scroll-controller.spec.ts +++ b/libraries/scrcpy/src/1_25/impl/scroll-controller.spec.ts @@ -1,6 +1,8 @@ import * as assert from "node:assert"; import { describe, it } from "node:test"; +import { Uint8ArrayExactReadable } from "@yume-chan/struct"; + import { ScrcpyControlMessageType } from "../../base/index.js"; import { ScrollController, SignedFloat } from "./scroll-controller.js"; @@ -65,30 +67,27 @@ describe("SignedFloat", () => { dataView.setInt16(0, -0x8000, true); assert.strictEqual( - SignedFloat.deserialize({ - runtimeStruct: {} as never, - reader: { position: 0, readExactly: () => view }, + SignedFloat.deserialize(new Uint8ArrayExactReadable(view), { littleEndian: true, + dependencies: {} as never, }), -1, ); dataView.setInt16(0, 0, true); assert.strictEqual( - SignedFloat.deserialize({ - runtimeStruct: {} as never, - reader: { position: 0, readExactly: () => view }, + SignedFloat.deserialize(new Uint8ArrayExactReadable(view), { littleEndian: true, + dependencies: {} as never, }), 0, ); dataView.setInt16(0, 0x7fff, true); assert.strictEqual( - SignedFloat.deserialize({ - runtimeStruct: {} as never, - reader: { position: 0, readExactly: () => view }, + SignedFloat.deserialize(new Uint8ArrayExactReadable(view), { littleEndian: true, + dependencies: {} as never, }), 1, ); diff --git a/libraries/scrcpy/src/1_25/impl/scroll-controller.ts b/libraries/scrcpy/src/1_25/impl/scroll-controller.ts index 5bbeaea3..01cc672c 100644 --- a/libraries/scrcpy/src/1_25/impl/scroll-controller.ts +++ b/libraries/scrcpy/src/1_25/impl/scroll-controller.ts @@ -1,27 +1,28 @@ import { getInt16, setInt16 } from "@yume-chan/no-data-view"; import type { Field, StructInit } from "@yume-chan/struct"; -import { bipedal, struct, u16, u32, u8 } from "@yume-chan/struct"; +import { field, struct, u16, u32, u8 } from "@yume-chan/struct"; import type { ScrcpyScrollController } from "../../base/index.js"; import { ScrcpyControlMessageType } from "../../base/index.js"; import type { ScrcpyInjectScrollControlMessage } from "../../latest.js"; import { clamp } from "../../utils/index.js"; -export const SignedFloat: Field = { - size: 2, - serialize(value, { buffer, index, littleEndian }) { +export const SignedFloat: Field = field( + 2, + "byob", + (value, { buffer, index, littleEndian }) => { // https://github.com/Genymobile/scrcpy/blob/1f138aef41de651668043b32c4effc2d4adbfc44/app/src/util/binary.h#L51 value = clamp(value, -1, 1); value = value === 1 ? 0x7fff : value * 0x8000; setInt16(buffer, index, value, littleEndian); }, - deserialize: bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(2)); const value = getInt16(data, 0, littleEndian); // https://github.com/Genymobile/scrcpy/blob/1f138aef41de651668043b32c4effc2d4adbfc44/server/src/main/java/com/genymobile/scrcpy/Binary.java#L34 return value === 0x7fff ? 1 : value / 0x8000; - }), -}; + }, +); export const InjectScrollControlMessage = /* #__PURE__ */ (() => struct( diff --git a/libraries/stream-extra/src/struct-deserialize.ts b/libraries/stream-extra/src/struct-deserialize.ts index f24c039c..f1a69725 100644 --- a/libraries/stream-extra/src/struct-deserialize.ts +++ b/libraries/stream-extra/src/struct-deserialize.ts @@ -1,9 +1,9 @@ -import type { StructLike } from "@yume-chan/struct"; +import type { StructDeserializer } from "@yume-chan/struct"; import { BufferedTransformStream } from "./buffered-transform.js"; export class StructDeserializeStream extends BufferedTransformStream { - constructor(struct: StructLike) { + constructor(struct: StructDeserializer) { super((stream) => { return struct.deserialize(stream) as never; }); diff --git a/libraries/stream-extra/src/struct-serialize.ts b/libraries/stream-extra/src/struct-serialize.ts index 86e9a6ff..46596820 100644 --- a/libraries/stream-extra/src/struct-serialize.ts +++ b/libraries/stream-extra/src/struct-serialize.ts @@ -1,9 +1,9 @@ -import type { StructInit, StructLike } from "@yume-chan/struct"; +import type { StructInit, StructSerializer } from "@yume-chan/struct"; import { TransformStream } from "./stream.js"; export class StructSerializeStream< - T extends StructLike, + T extends StructSerializer, > extends TransformStream, Uint8Array> { constructor(struct: T) { super({ diff --git a/libraries/struct/src/bipedal.ts b/libraries/struct/src/bipedal.ts index 96a24b55..56588659 100644 --- a/libraries/struct/src/bipedal.ts +++ b/libraries/struct/src/bipedal.ts @@ -20,18 +20,22 @@ function advance( } } +export type BipedalGenerator = ( + this: This, + then: (value: MaybePromiseLike) => Iterable, + ...args: A +) => Generator; + +/* #__NO_SIDE_EFFECTS__ */ export function bipedal( - fn: ( - this: This, - then: (value: U | PromiseLike) => Iterable, - ...args: A - ) => Generator, + fn: BipedalGenerator, + bindThis?: This, ): { (this: This, ...args: A): MaybePromiseLike } { - return function (this: This, ...args: A) { + function result(this: This, ...args: A): MaybePromiseLike { const iterator = fn.call( this, function* ( - value: U | PromiseLike, + value: MaybePromiseLike, ): Generator< PromiseLike, U, @@ -51,5 +55,11 @@ export function bipedal( ...args, ) as never; return advance(iterator, undefined); - }; + } + + if (bindThis) { + return result.bind(bindThis); + } else { + return result; + } } diff --git a/libraries/struct/src/buffer.ts b/libraries/struct/src/buffer.ts index 4e1f05f9..f43d9a13 100644 --- a/libraries/struct/src/buffer.ts +++ b/libraries/struct/src/buffer.ts @@ -1,5 +1,7 @@ -import { bipedal } from "./bipedal.js"; -import type { Field } from "./field.js"; +import type { Field } from "./field/index.js"; +import { field } from "./field/index.js"; + +export const EmptyUint8Array = new Uint8Array(0); export interface Converter { convert: (value: From) => To; @@ -11,246 +13,306 @@ export interface BufferLengthConverter extends Converter { } export interface BufferLike { - (length: number): Field; + (length: number): Field; ( length: number, converter: Converter, - ): Field; + ): Field; - (lengthField: K): Field>; + ( + lengthField: K, + ): Field, Uint8Array>; ( lengthField: K, converter: Converter, - ): Field>; + ): Field, Uint8Array>; ( length: BufferLengthConverter, - ): Field>; + ): Field, Uint8Array>; ( length: BufferLengthConverter, converter: Converter, - ): Field>; + ): Field, Uint8Array>; - ( - length: Field, - ): Field; - ( - length: Field, + ( + length: Field, + ): Field; + ( + length: Field, converter: Converter, - ): Field; + ): Field; } -export const EmptyUint8Array = new Uint8Array(0); +function _buffer(length: number): Field; +function _buffer( + length: number, + converter: Converter, +): Field; -// Prettier will move the annotation and make it invalid -// prettier-ignore -export const buffer: BufferLike = (/* #__NO_SIDE_EFFECTS__ */ ( +function _buffer( + lengthField: K, +): Field, Uint8Array>; +function _buffer( + lengthField: K, + converter: Converter, +): Field, Uint8Array>; + +function _buffer( + length: BufferLengthConverter, +): Field, Uint8Array>; +function _buffer( + length: BufferLengthConverter, + converter: Converter, +): Field, Uint8Array>; + +function _buffer( + length: Field, +): Field; +function _buffer( + length: Field, + converter: Converter, +): Field; + +/* #__NO_SIDE_EFFECTS__ */ +function _buffer( lengthOrField: | string | number - | Field + | Field | BufferLengthConverter, converter?: Converter, -): Field> => { +): Field, Uint8Array> { + // Fixed length if (typeof lengthOrField === "number") { if (converter) { if (lengthOrField === 0) { - return { - size: 0, - serialize: () => {}, - deserialize: () => converter.convert(EmptyUint8Array), - }; + return field( + 0, + "byob", + () => {}, + // eslint-disable-next-line require-yield + function* () { + return converter.convert(EmptyUint8Array); + }, + ); } - return { - size: lengthOrField, - serialize: (value, { buffer, index }) => { - buffer.set( - converter.back(value).slice(0, lengthOrField), - index, - ); + return field( + 0, + "byob", + (value, { buffer, index }) => { + buffer.set(value.slice(0, lengthOrField), index); }, - deserialize: bipedal(function* (then, { reader }) { + function* (then, reader) { const array = yield* then( reader.readExactly(lengthOrField), ); return converter.convert(array); - }), - }; + }, + { + init(value) { + return converter.back(value); + }, + }, + ); } if (lengthOrField === 0) { - return { - size: 0, - serialize: () => {}, - deserialize: () => EmptyUint8Array, - }; + return field( + 0, + "byob", + () => {}, + // eslint-disable-next-line require-yield + function* () { + return EmptyUint8Array; + }, + ); } - return { - size: lengthOrField, - serialize: (value, { buffer, index }) => { - buffer.set( - (value as Uint8Array).slice(0, lengthOrField), - index, - ); + return field( + 0, + "byob", + (value, { buffer, index }) => { + buffer.set(value.slice(0, lengthOrField), index); }, - deserialize: ({ reader }) => reader.readExactly(lengthOrField), - }; + // eslint-disable-next-line require-yield + function* (_then, reader) { + return reader.readExactly(lengthOrField); + }, + ); } - // Some Field type might be `function`s + // Declare length field + // Some field types are `function`s if ( (typeof lengthOrField === "object" || typeof lengthOrField === "function") && "serialize" in lengthOrField ) { if (converter) { - return { - size: 0, - dynamicSize(value) { - const array = converter.back(value); - const lengthFieldSize = - lengthOrField.dynamicSize?.(array.length) ?? - lengthOrField.size; - return lengthFieldSize + array.length; + return field( + lengthOrField.size, + "default", + (value, { littleEndian }) => { + if (lengthOrField.type === "default") { + const lengthBuffer = lengthOrField.serialize( + value.length, + { littleEndian }, + ); + const result = new Uint8Array( + lengthBuffer.length + value.length, + ); + result.set(lengthBuffer, 0); + result.set(value, lengthBuffer.length); + return result; + } else { + const result = new Uint8Array( + lengthOrField.size + value.length, + ); + lengthOrField.serialize(value.length, { + buffer: result, + index: 0, + littleEndian, + }); + result.set(value, lengthOrField.size); + return result; + } }, - serialize(value, context) { - const array = converter.back(value); - const lengthFieldSize = - lengthOrField.dynamicSize?.(array.length) ?? - lengthOrField.size; - lengthOrField.serialize(array.length, context); - context.buffer.set(array, context.index + lengthFieldSize); - }, - deserialize: bipedal(function* (then, context) { + function* (then, reader, context) { const length = yield* then( - lengthOrField.deserialize(context), - ); - const array = yield* then( - context.reader.readExactly(length), + lengthOrField.deserialize(reader, context), ); + const array = yield* then(reader.readExactly(length)); return converter.convert(array); - }), - }; + }, + { + init(value) { + return converter.back(value); + }, + }, + ); } - return { - size: 0, - dynamicSize(value) { - const lengthFieldSize = - lengthOrField.dynamicSize?.((value as Uint8Array).length) ?? - lengthOrField.size; - return lengthFieldSize + (value as Uint8Array).length; + return field( + lengthOrField.size, + "default", + (value, { littleEndian }) => { + if (lengthOrField.type === "default") { + const lengthBuffer = lengthOrField.serialize(value.length, { + littleEndian, + }); + const result = new Uint8Array( + lengthBuffer.length + value.length, + ); + result.set(lengthBuffer, 0); + result.set(value, lengthBuffer.length); + return result; + } else { + const result = new Uint8Array( + lengthOrField.size + value.length, + ); + lengthOrField.serialize(value.length, { + buffer: result, + index: 0, + littleEndian, + }); + result.set(value, lengthOrField.size); + return result; + } }, - serialize(value, context) { - const lengthFieldSize = - lengthOrField.dynamicSize?.((value as Uint8Array).length) ?? - lengthOrField.size; - lengthOrField.serialize((value as Uint8Array).length, context); - context.buffer.set( - value as Uint8Array, - context.index + lengthFieldSize, + function* (then, reader, context) { + const length = yield* then( + lengthOrField.deserialize(reader, context), ); + return yield* then(reader.readExactly(length)); }, - deserialize: bipedal(function* (then, context) { - const length = yield* then(lengthOrField.deserialize(context)); - return context.reader.readExactly(length); - }), - }; + ); } + // Reference exiting length field if (typeof lengthOrField === "string") { if (converter) { - return { - size: 0, - preSerialize: (value, runtimeStruct) => { - runtimeStruct[lengthOrField] = converter.back(value).length; - }, - dynamicSize: (value) => { - return converter.back(value).length; - }, - serialize: (value, { buffer, index }) => { - buffer.set(converter.back(value), index); - }, - deserialize: bipedal(function* ( - then, - { reader, runtimeStruct }, - ) { - const length = runtimeStruct[lengthOrField] as number; + return field( + 0, + "default", + (source) => source, + // eslint-disable-next-line require-yield + function* (_then, reader, { dependencies }) { + const length = dependencies[lengthOrField] as number; if (length === 0) { - return converter.convert(EmptyUint8Array); + return EmptyUint8Array; } - const value = yield* then(reader.readExactly(length)); - return converter.convert(value); - }), - }; + return reader.readExactly(length); + }, + { + init(value, dependencies) { + const array = converter.back(value); + dependencies[lengthOrField] = array.length; + return array; + }, + }, + ); } - return { - size: 0, - preSerialize: (value, runtimeStruct) => { - runtimeStruct[lengthOrField] = (value as Uint8Array).length; - }, - dynamicSize: (value) => { - return (value as Uint8Array).length; - }, - serialize: (value, { buffer, index }) => { - buffer.set(value as Uint8Array, index); - }, - deserialize: ({ reader, runtimeStruct }) => { - const length = runtimeStruct[lengthOrField] as number; + return field( + 0, + "default", + (source) => source, + // eslint-disable-next-line require-yield + function* (_then, reader, { dependencies }) { + const length = dependencies[lengthOrField] as number; if (length === 0) { return EmptyUint8Array; } return reader.readExactly(length); }, - }; + { + init(value, dependencies) { + dependencies[lengthOrField] = (value as Uint8Array).length; + return undefined; + }, + }, + ); } + // Reference existing length field + converter if (converter) { - return { - size: 0, - preSerialize: (value, runtimeStruct) => { - const length = converter.back(value).length; - runtimeStruct[lengthOrField.field] = lengthOrField.back(length); - }, - dynamicSize: (value) => { - return converter.back(value).length; - }, - serialize: (value, { buffer, index }) => { - buffer.set(converter.back(value), index); - }, - deserialize: bipedal(function* (then, { reader, runtimeStruct }) { - const rawLength = runtimeStruct[lengthOrField.field]; + return field( + 0, + "default", + (source) => source, + // eslint-disable-next-line require-yield + function* (_then, reader, { dependencies }) { + const rawLength = dependencies[lengthOrField.field]; const length = lengthOrField.convert(rawLength); if (length === 0) { - return converter.convert(EmptyUint8Array); + return EmptyUint8Array; } - const value = yield* then(reader.readExactly(length)); - return converter.convert(value); - }), - }; + return reader.readExactly(length); + }, + { + init(value, dependencies) { + const array = converter.back(value); + dependencies[lengthOrField.field] = lengthOrField.back( + array.length, + ); + return array; + }, + }, + ); } - return { - size: 0, - preSerialize: (value, runtimeStruct) => { - runtimeStruct[lengthOrField.field] = lengthOrField.back( - (value as Uint8Array).length, - ); - }, - dynamicSize: (value) => { - return (value as Uint8Array).length; - }, - serialize: (value, { buffer, index }) => { - buffer.set(value as Uint8Array, index); - }, - deserialize: ({ reader, runtimeStruct }) => { - const rawLength = runtimeStruct[lengthOrField.field]; + return field( + 0, + "default", + (source) => source, + // eslint-disable-next-line require-yield + function* (_then, reader, { dependencies }) { + const rawLength = dependencies[lengthOrField.field]; const length = lengthOrField.convert(rawLength); if (length === 0) { return EmptyUint8Array; @@ -258,5 +320,15 @@ export const buffer: BufferLike = (/* #__NO_SIDE_EFFECTS__ */ ( return reader.readExactly(length); }, - }; -}) as never; + { + init(value, dependencies) { + dependencies[lengthOrField.field] = lengthOrField.back( + (value as Uint8Array).length, + ); + return undefined; + }, + }, + ); +} + +export const buffer = _buffer; diff --git a/libraries/struct/src/concat.ts b/libraries/struct/src/concat.ts new file mode 100644 index 00000000..92709bc4 --- /dev/null +++ b/libraries/struct/src/concat.ts @@ -0,0 +1,78 @@ +import type { FieldsValue, Struct, StructFields } from "./struct.js"; +import { struct } from "./struct.js"; + +type UnionToIntersection = ( + U extends unknown ? (x: U) => void : never +) extends (x: infer I) => void + ? I + : never; + +type As = T extends infer V extends U ? V : never; + +export type ConcatFields< + T extends Struct< + StructFields, + Record | undefined, + unknown + >[], +> = As, StructFields>; + +type ConcatFieldValues< + T extends Struct< + StructFields, + Record | undefined, + unknown + >[], +> = FieldsValue>; + +type ExtraToUnion | undefined> = + Extra extends undefined ? never : Extra; + +export type ConcatExtras< + T extends Struct< + StructFields, + Record | undefined, + unknown + >[], +> = As< + UnionToIntersection>, + Record +>; + +/* #__NO_SIDE_EFFECTS__ */ +export function concat< + T extends Struct< + StructFields, + Record | undefined, + unknown + >[], + PostDeserialize = ConcatFieldValues & ConcatExtras, +>( + options: { + littleEndian: boolean; + postDeserialize?: ( + this: ConcatFieldValues & ConcatExtras, + value: ConcatFieldValues & ConcatExtras, + ) => PostDeserialize; + }, + ...structs: T +): Struct, ConcatExtras, PostDeserialize> { + return struct( + structs.reduce( + (fields, struct) => Object.assign(fields, struct.fields), + {}, + ) as never, + { + littleEndian: options.littleEndian, + postDeserialize: options.postDeserialize, + extra: structs.reduce( + (extras, struct) => + Object.defineProperties( + extras, + Object.getOwnPropertyDescriptors(struct.extra), + ), + {}, + ) as never, + }, + ) as never; +} diff --git a/libraries/struct/src/extend.ts b/libraries/struct/src/extend.ts new file mode 100644 index 00000000..11eacb9b --- /dev/null +++ b/libraries/struct/src/extend.ts @@ -0,0 +1,37 @@ +import type { + ExtraToIntersection, + FieldsValue, + Struct, + StructFields, +} from "./struct.js"; +import { struct } from "./struct.js"; + +/* #__NO_SIDE_EFFECTS__ */ +export function extend< + Base extends Struct< + StructFields, + Record | undefined, + unknown + >, + Fields extends StructFields, + PostDeserialize = FieldsValue & + ExtraToIntersection, +>( + base: Base, + fields: Fields, + options?: { + littleEndian?: boolean | undefined; + postDeserialize?: ( + this: FieldsValue & + ExtraToIntersection, + value: FieldsValue & + ExtraToIntersection, + ) => PostDeserialize; + }, +): Struct { + return struct(Object.assign({}, base.fields, fields), { + littleEndian: options?.littleEndian ?? base.littleEndian, + extra: base.extra as never, + postDeserialize: options?.postDeserialize, + }) as never; +} diff --git a/libraries/struct/src/field.ts b/libraries/struct/src/field.ts deleted file mode 100644 index 0a415a75..00000000 --- a/libraries/struct/src/field.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MaybePromiseLike } from "@yume-chan/async"; - -import type { AsyncExactReadable } from "./readable.js"; - -export interface SerializeContext { - buffer: Uint8Array; - index: number; - littleEndian: boolean; -} - -export interface DeserializeContext { - reader: AsyncExactReadable; - littleEndian: boolean; - runtimeStruct: S; -} - -export interface Field { - __invariant?: OmitInit; - - size: number; - - dynamicSize?(value: T): number; - preSerialize?(value: T, runtimeStruct: S): void; - serialize(value: T, context: SerializeContext): void; - - deserialize(context: DeserializeContext): MaybePromiseLike; -} diff --git a/libraries/struct/src/field/factory.ts b/libraries/struct/src/field/factory.ts new file mode 100644 index 00000000..1edd5c61 --- /dev/null +++ b/libraries/struct/src/field/factory.ts @@ -0,0 +1,64 @@ +import type { BipedalGenerator } from "../bipedal.js"; +import { bipedal } from "../bipedal.js"; +import type { AsyncExactReadable } from "../readable.js"; + +import type { + ByobFieldSerializer, + DefaultFieldSerializer, +} from "./serialize.js"; +import { byobFieldSerializer, defaultFieldSerializer } from "./serialize.js"; +import type { Field, FieldDeserializeContext, FieldOptions } from "./types.js"; + +export type MaybeBipedalFieldDeserializer = BipedalGenerator< + undefined, + T, + [reader: AsyncExactReadable, context: FieldDeserializeContext] +>; + +// eslint-disable-next-line @typescript-eslint/max-params +function _field( + size: number, + type: "default", + serialize: DefaultFieldSerializer, + deserialize: MaybeBipedalFieldDeserializer, + options?: FieldOptions, +): Field; +// eslint-disable-next-line @typescript-eslint/max-params +function _field( + size: number, + type: "byob", + serialize: ByobFieldSerializer, + deserialize: MaybeBipedalFieldDeserializer, + options?: FieldOptions, +): Field; +/* #__NO_SIDE_EFFECTS__ */ +// eslint-disable-next-line @typescript-eslint/max-params +function _field( + size: number, + type: "default" | "byob", + serialize: DefaultFieldSerializer | ByobFieldSerializer, + deserialize: MaybeBipedalFieldDeserializer, + options?: FieldOptions, +): Field { + const field: Field = { + size, + type: type, + serialize: + type === "default" + ? defaultFieldSerializer( + serialize as DefaultFieldSerializer, + ) + : byobFieldSerializer( + size, + serialize as ByobFieldSerializer, + ), + deserialize: bipedal(deserialize) as never, + omitInit: options?.omitInit, + }; + if (options?.init) { + field.init = options.init; + } + return field; +} + +export const field = _field; diff --git a/libraries/struct/src/field/index.ts b/libraries/struct/src/field/index.ts new file mode 100644 index 00000000..4ff07525 --- /dev/null +++ b/libraries/struct/src/field/index.ts @@ -0,0 +1,3 @@ +export * from "./factory.js"; +export * from "./serialize.js"; +export * from "./types.js"; diff --git a/libraries/struct/src/field/serialize.ts b/libraries/struct/src/field/serialize.ts new file mode 100644 index 00000000..36d9ca52 --- /dev/null +++ b/libraries/struct/src/field/serialize.ts @@ -0,0 +1,58 @@ +import type { + FieldByobSerializeContext, + FieldDefaultSerializeContext, + FieldSerializer, +} from "./types.js"; + +export type DefaultFieldSerializer = ( + source: T, + context: FieldDefaultSerializeContext, +) => Uint8Array; + +/* Adapt default field serializer to universal field serializer */ +export function defaultFieldSerializer( + serializer: DefaultFieldSerializer, +): FieldSerializer["serialize"] { + return ( + source, + context: FieldDefaultSerializeContext | FieldByobSerializeContext, + ): never => { + if ("buffer" in context) { + const buffer = serializer(source, context); + context.buffer.set(buffer, context.index); + return buffer.length as never; + } else { + return serializer(source, context) as never; + } + }; +} + +export type ByobFieldSerializer = ( + source: T, + context: FieldByobSerializeContext & { index: number }, +) => void; + +/* Adapt byob field serializer to universal field serializer */ +export function byobFieldSerializer( + size: number, + serializer: ByobFieldSerializer, +): FieldSerializer["serialize"] { + return ( + source, + context: FieldDefaultSerializeContext | FieldByobSerializeContext, + ): never => { + if ("buffer" in context) { + context.index ??= 0; + serializer(source, context as never); + return size as never; + } else { + const buffer = new Uint8Array(size); + serializer(source, { + buffer, + index: 0, + littleEndian: context.littleEndian, + }); + return buffer as never; + } + }; +} diff --git a/libraries/struct/src/field/types.ts b/libraries/struct/src/field/types.ts new file mode 100644 index 00000000..5b520942 --- /dev/null +++ b/libraries/struct/src/field/types.ts @@ -0,0 +1,48 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; + +import type { AsyncExactReadable, ExactReadable } from "../readable.js"; + +export interface FieldDefaultSerializeContext { + littleEndian: boolean; +} + +export interface FieldByobSerializeContext + extends FieldDefaultSerializeContext { + buffer: Uint8Array; + index?: number; +} + +export interface FieldSerializer { + type: "default" | "byob"; + size: number; + + serialize(source: T, context: FieldDefaultSerializeContext): Uint8Array; + serialize(source: T, context: FieldByobSerializeContext): number; +} + +export interface Field + extends FieldSerializer, + FieldDeserializer { + omitInit: OmitInit | undefined; + + init?(value: T, dependencies: D): Raw | undefined; +} + +export interface FieldDeserializeContext { + littleEndian: boolean; + dependencies: D; +} + +export interface FieldDeserializer { + deserialize(reader: ExactReadable, context: FieldDeserializeContext): T; + deserialize( + reader: AsyncExactReadable, + context: FieldDeserializeContext, + ): MaybePromiseLike; +} + +export interface FieldOptions { + omitInit?: OmitInit; + dependencies?: D; + init?: (value: T, dependencies: D) => Raw | undefined; +} diff --git a/libraries/struct/src/index.ts b/libraries/struct/src/index.ts index bf82fbb9..f56b7738 100644 --- a/libraries/struct/src/index.ts +++ b/libraries/struct/src/index.ts @@ -12,9 +12,12 @@ declare global { export * from "./bipedal.js"; export * from "./buffer.js"; -export * from "./field.js"; +export * from "./concat.js"; +export * from "./extend.js"; +export * from "./field/index.js"; export * from "./number.js"; export * from "./readable.js"; export * from "./string.js"; export * from "./struct.js"; +export * from "./types.js"; export * from "./utils.js"; diff --git a/libraries/struct/src/number.spec.ts b/libraries/struct/src/number.spec.ts new file mode 100644 index 00000000..91e174f2 --- /dev/null +++ b/libraries/struct/src/number.spec.ts @@ -0,0 +1,71 @@ +import * as assert from "node:assert"; +import { describe, it } from "node:test"; + +import type { Field } from "./field/index.js"; +import { s16, s32, s8, u16, u32, u8 } from "./number.js"; + +function testNumber( + name: string, + field: Field, + size: number, + signed: boolean, +) { + describe(name, () => { + it("should match size", () => { + assert.strictEqual(field.size, size); + }); + + describe("serialize", () => { + it("should serialize min value", () => { + const minValue = signed ? -(2 ** (size * 8 - 1)) : 0; + const buffer = field.serialize(minValue, { + littleEndian: true, + }); + const expected = new Uint8Array(size); + expected[size - 1] = signed ? 0x80 : 0x00; + assert.deepStrictEqual(buffer, expected); + }); + + it("should serialize 0", () => { + const buffer = field.serialize(0, { + littleEndian: true, + }); + const expected = new Uint8Array(size); + assert.deepStrictEqual(buffer, expected); + }); + + it("should serialize 1", () => { + const buffer = field.serialize(1, { + littleEndian: true, + }); + const expected = new Uint8Array(size); + expected[0] = 1; + assert.deepStrictEqual(buffer, expected); + }); + + it("should serialize max value", () => { + const maxValue = signed + ? 2 ** (size * 8 - 1) - 1 + : 2 ** (size * 8) - 1; + const buffer = field.serialize(maxValue, { + littleEndian: true, + }); + const expected = new Uint8Array(size); + for (let i = 0; i < size - 1; i += 1) { + expected[i] = 0xff; + } + expected[size - 1] = signed ? 0x7f : 0xff; + assert.deepStrictEqual(buffer, expected); + }); + }); + }); +} + +describe("number", () => { + testNumber("u8", u8, 1, false); + testNumber("s8", s8, 1, true); + testNumber("u16", u16, 2, false); + testNumber("s16", s16, 2, true); + testNumber("u32", u32, 4, false); + testNumber("s32", s32, 4, true); +}); diff --git a/libraries/struct/src/number.ts b/libraries/struct/src/number.ts index 1eea6d5e..9b21cacb 100644 --- a/libraries/struct/src/number.ts +++ b/libraries/struct/src/number.ts @@ -1,3 +1,4 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; import { getInt16, getInt32, @@ -14,110 +15,120 @@ import { setUint64, } from "@yume-chan/no-data-view"; -import { bipedal } from "./bipedal.js"; -import type { Field } from "./field.js"; +import type { + Field, + FieldByobSerializeContext, + FieldDeserializeContext, +} from "./field/index.js"; +import { field } from "./field/index.js"; +import type { AsyncExactReadable } from "./readable.js"; -export interface NumberField extends Field { - (infer?: U): Field; +export interface NumberField extends Field { + (infer?: U): Field; } /* #__NO_SIDE_EFFECTS__ */ -function factory( +function number( size: number, - serialize: Field["serialize"], - deserialize: Field["deserialize"], + serialize: ( + source: T, + context: FieldByobSerializeContext & { index: number }, + ) => void, + deserialize: ( + then: (value: MaybePromiseLike) => Iterable, + reader: AsyncExactReadable, + context: FieldDeserializeContext, + ) => Generator, ) { const fn: NumberField = (() => fn) as never; - fn.size = size; - fn.serialize = serialize; - fn.deserialize = deserialize; + Object.assign(fn, field(size, "byob", serialize, deserialize)); return fn; } -export const u8: NumberField = factory( +export const u8: NumberField = number( 1, (value, { buffer, index }) => { buffer[index] = value; }, - bipedal(function* (then, { reader }) { + function* (then, reader) { const data = yield* then(reader.readExactly(1)); return data[0]!; - }), + }, ); -export const s8: NumberField = factory( +export const s8: NumberField = number( 1, (value, { buffer, index }) => { buffer[index] = value; }, - bipedal(function* (then, { reader }) { + function* (then, reader) { const data = yield* then(reader.readExactly(1)); return getInt8(data, 0); - }), + }, ); -export const u16: NumberField = factory( +export const u16: NumberField = number( 2, (value, { buffer, index, littleEndian }) => { setUint16(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(2)); return getUint16(data, 0, littleEndian); - }), + }, ); -export const s16: NumberField = factory( +export const s16: NumberField = number( 2, (value, { buffer, index, littleEndian }) => { setInt16(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(2)); return getInt16(data, 0, littleEndian); - }), + }, ); -export const u32: NumberField = factory( +export const u32: NumberField = number( 4, (value, { buffer, index, littleEndian }) => { setUint32(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(4)); return getUint32(data, 0, littleEndian); - }), + }, ); -export const s32: NumberField = factory( +export const s32: NumberField = number( 4, (value, { buffer, index, littleEndian }) => { setInt32(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(4)); return getInt32(data, 0, littleEndian); - }), + }, ); -export const u64: NumberField = factory( +export const u64: NumberField = number( 8, (value, { buffer, index, littleEndian }) => { setUint64(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(8)); return getUint64(data, 0, littleEndian); - }), + }, ); -export const s64: NumberField = factory( +export const s64: NumberField = number( 8, (value, { buffer, index, littleEndian }) => { setInt64(buffer, index, value, littleEndian); }, - bipedal(function* (then, { reader, littleEndian }) { + function* (then, reader, { littleEndian }) { const data = yield* then(reader.readExactly(8)); return getInt64(data, 0, littleEndian); - }), + }, ); diff --git a/libraries/struct/src/readable.ts b/libraries/struct/src/readable.ts index 21df5f92..4280f68a 100644 --- a/libraries/struct/src/readable.ts +++ b/libraries/struct/src/readable.ts @@ -20,6 +20,34 @@ export interface ExactReadable { readExactly(length: number): Uint8Array; } +export class Uint8ArrayExactReadable implements ExactReadable { + #data: Uint8Array; + #position: number; + + get position() { + return this.#position; + } + + constructor(data: Uint8Array) { + this.#data = data; + this.#position = 0; + } + + readExactly(length: number): Uint8Array { + if (this.#position + length > this.#data.length) { + throw new ExactReadableEndedError(); + } + + const result = this.#data.subarray( + this.#position, + this.#position + length, + ); + + this.#position += length; + return result; + } +} + export interface AsyncExactReadable { readonly position: number; diff --git a/libraries/struct/src/string.ts b/libraries/struct/src/string.ts index 47be7fa7..c2238f3f 100644 --- a/libraries/struct/src/string.ts +++ b/libraries/struct/src/string.ts @@ -1,6 +1,6 @@ import type { BufferLengthConverter } from "./buffer.js"; import { buffer } from "./buffer.js"; -import type { Field } from "./field.js"; +import type { Field } from "./field/index.js"; import { decodeUtf8, encodeUtf8 } from "./utils.js"; export interface String { diff --git a/libraries/struct/src/struct.spec.ts b/libraries/struct/src/struct.spec.ts index 3fe9e5bb..56f748bd 100644 --- a/libraries/struct/src/struct.spec.ts +++ b/libraries/struct/src/struct.spec.ts @@ -1,7 +1,8 @@ import * as assert from "node:assert"; import { describe, it } from "node:test"; -import { u8 } from "./number.js"; +import { u16, u8 } from "./number.js"; +import { Uint8ArrayExactReadable } from "./readable.js"; import { struct } from "./struct.js"; describe("Struct", () => { @@ -9,4 +10,24 @@ describe("Struct", () => { const A = struct({ id: u8 }, { littleEndian: true }); assert.deepStrictEqual(A.serialize({ id: 10 }), new Uint8Array([10])); }); + + it("use struct as field", () => { + const B = struct( + { foo: struct({ bar: u8 }, { littleEndian: true }), baz: u16 }, + { littleEndian: true }, + ); + assert.deepStrictEqual( + B.serialize({ foo: { bar: 10 }, baz: 20 }), + new Uint8Array([10, 20, 0]), + ); + assert.deepStrictEqual( + B.deserialize( + new Uint8ArrayExactReadable(new Uint8Array([10, 20, 0])), + ), + { + foo: { bar: 10 }, + baz: 20, + }, + ); + }); }); diff --git a/libraries/struct/src/struct.ts b/libraries/struct/src/struct.ts index 99d1038a..6ce66d28 100644 --- a/libraries/struct/src/struct.ts +++ b/libraries/struct/src/struct.ts @@ -1,37 +1,46 @@ -import type { MaybePromiseLike } from "@yume-chan/async"; - import { bipedal } from "./bipedal.js"; -import type { DeserializeContext, Field, SerializeContext } from "./field.js"; -import type { AsyncExactReadable, ExactReadable } from "./readable.js"; +import type { + Field, + FieldByobSerializeContext, + FieldDefaultSerializeContext, + FieldDeserializeContext, + FieldDeserializer, +} from "./field/index.js"; +import type { AsyncExactReadable } from "./readable.js"; import { ExactReadableEndedError } from "./readable.js"; +import type { + StructDeserializer, + StructSerializeContext, + StructSerializer, +} from "./types.js"; -export type FieldsType< - T extends Record>, -> = { - [K in keyof T]: T[K] extends Field ? TK : never; +export type StructField = + | Field + | (StructSerializer & StructDeserializer); + +export type StructFields = Record; + +export type FieldsValue = { + [K in keyof T]: T[K] extends FieldDeserializer + ? U + : never; }; -export type StructInit< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Struct, -> = Omit< - FieldsType, - { - [K in keyof T["fields"]]: T["fields"][K] extends Field< - unknown, - infer U, - unknown - > - ? U - : never; - }[keyof T["fields"]] ->; +export type FieldOmitInit = + T extends Field + ? string extends U + ? never + : U + : never; -export type StructValue< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Struct, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -> = T extends Struct ? P : never; +export type FieldsOmitInits = { + [K in keyof T]: FieldOmitInit; +}[keyof T]; + +export type FieldsInit = Omit< + FieldsValue, + FieldsOmitInits +>; export class StructDeserializeError extends Error { constructor(message: string) { @@ -53,92 +62,125 @@ export class StructEmptyError extends StructDeserializeError { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type StructLike = Struct; +export type ExtraToIntersection< + Extra extends Record | undefined, +> = Extra extends undefined ? unknown : Extra; export interface Struct< - T extends Record>>>, + Fields extends StructFields, Extra extends Record | undefined = undefined, - PostDeserialize = FieldsType & Extra, -> { - fields: T; - size: number; + PostDeserialize = FieldsValue & Extra, +> extends StructSerializer>, + StructDeserializer { + littleEndian: boolean; + fields: Fields; extra: Extra; - - serialize(runtimeStruct: StructInit): Uint8Array; - serialize(runtimeStruct: StructInit, buffer: Uint8Array): number; - - deserialize(reader: ExactReadable): PostDeserialize; - deserialize(reader: AsyncExactReadable): MaybePromiseLike; } /* #__NO_SIDE_EFFECTS__ */ export function struct< - T extends Record>>>, - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - Extra extends Record = {}, - PostDeserialize = FieldsType & Extra, + Fields extends Record< + string, + | Field>, unknown> + | (StructSerializer & StructDeserializer) + >, + Extra extends Record | undefined = undefined, + PostDeserialize = FieldsValue & ExtraToIntersection, >( - fields: T, + fields: Fields, options: { - littleEndian?: boolean; - extra?: Extra & ThisType>; - postDeserialize?: ( - this: FieldsType & Extra, - fields: FieldsType & Extra, - ) => PostDeserialize; + littleEndian: boolean; + extra?: (Extra & ThisType>) | undefined; + postDeserialize?: + | (( + this: FieldsValue & ExtraToIntersection, + value: FieldsValue & ExtraToIntersection, + ) => PostDeserialize) + | undefined; }, -): Struct { +): Struct { const fieldList = Object.entries(fields); const size = fieldList.reduce((sum, [, field]) => sum + field.size, 0); - const littleEndian = !!options.littleEndian; + const littleEndian = options.littleEndian; const extra = options.extra ? Object.getOwnPropertyDescriptors(options.extra) : undefined; return { + littleEndian, + type: "byob", fields, size, extra: options.extra, serialize( - runtimeStruct: StructInit>, - buffer?: Uint8Array, + source: FieldsInit, + bufferOrContext?: Uint8Array | StructSerializeContext, ): Uint8Array | number { + const temp: Record = { ...source }; + for (const [key, field] of fieldList) { - if (key in runtimeStruct) { - field.preSerialize?.( - runtimeStruct[key as never], - runtimeStruct as never, - ); + if (key in temp && "init" in field) { + const result = field.init?.(temp[key], temp as never); + if (result !== undefined) { + temp[key] = result; + } + } + } + + const sizes = new Array(fieldList.length); + const buffers = new Array(fieldList.length); + { + const context: FieldDefaultSerializeContext = { littleEndian }; + for (const [index, [key, field]] of fieldList.entries()) { + if (field.type === "byob") { + sizes[index] = field.size; + } else { + buffers[index] = field.serialize(temp[key], context); + sizes[index] = buffers[index].length; + } } } - const sizes = fieldList.map( - ([key, field]) => - field.dynamicSize?.(runtimeStruct[key as never]) ?? - field.size, - ); const size = sizes.reduce((sum, size) => sum + size, 0); - let externalBuffer = false; - if (buffer) { - if (buffer.length < size) { + let externalBuffer: boolean; + let buffer: Uint8Array; + let index: number; + if (bufferOrContext instanceof Uint8Array) { + if (bufferOrContext.length < size) { throw new Error("Buffer too small"); } - externalBuffer = true; + buffer = bufferOrContext; + index = 0; + } else if ( + typeof bufferOrContext === "object" && + "buffer" in bufferOrContext + ) { + externalBuffer = true; + buffer = bufferOrContext.buffer; + index = bufferOrContext.index ?? 0; + if (buffer.length - index < size) { + throw new Error("Buffer too small"); + } } else { + externalBuffer = false; buffer = new Uint8Array(size); + index = 0; } - const context: SerializeContext = { + const context = { buffer, - index: 0, + index, littleEndian, - }; + } satisfies FieldByobSerializeContext; for (const [index, [key, field]] of fieldList.entries()) { - field.serialize(runtimeStruct[key as never], context); + if (buffers[index]) { + buffer.set(buffers[index], context.index); + } else { + field.serialize(temp[key], context); + } context.index += sizes[index]!; } @@ -149,23 +191,24 @@ export function struct< } }, deserialize: bipedal(function* ( - this: Struct, + this: Struct, then, reader: AsyncExactReadable, ) { const startPosition = reader.position; - const runtimeStruct = {} as Record; - const context: DeserializeContext>> = { - reader, - runtimeStruct: runtimeStruct as never, + const result = {} as Record; + const context: FieldDeserializeContext< + Partial> + > = { + dependencies: result as never, littleEndian: littleEndian, }; try { for (const [key, field] of fieldList) { - runtimeStruct[key] = yield* then( - field.deserialize(context), + result[key] = yield* then( + field.deserialize(reader, context), ); } } catch (e) { @@ -181,16 +224,16 @@ export function struct< } if (extra) { - Object.defineProperties(runtimeStruct, extra); + Object.defineProperties(result, extra); } if (options.postDeserialize) { return options.postDeserialize.call( - runtimeStruct as never, - runtimeStruct as never, + result as never, + result as never, ); } else { - return runtimeStruct; + return result; } }), } as never; diff --git a/libraries/struct/src/types.ts b/libraries/struct/src/types.ts new file mode 100644 index 00000000..0ebbbc94 --- /dev/null +++ b/libraries/struct/src/types.ts @@ -0,0 +1,37 @@ +import type { MaybePromiseLike } from "@yume-chan/async"; + +import type { + FieldByobSerializeContext, + FieldDeserializer, + FieldSerializer, +} from "./field/index.js"; +import type { AsyncExactReadable, ExactReadable } from "./readable.js"; + +export type StructSerializeContext = Omit< + FieldByobSerializeContext, + "littleEndian" +>; + +export interface StructSerializer extends FieldSerializer { + type: "byob"; + size: number; + + serialize(source: T): Uint8Array; + serialize(source: T, buffer: Uint8Array): number; + serialize(source: T, context: StructSerializeContext): number; +} + +export type StructInit> = + T extends StructSerializer ? U : never; + +export interface StructDeserializer extends FieldDeserializer { + size: number; + + deserialize(reader: ExactReadable): T; + deserialize(reader: AsyncExactReadable): MaybePromiseLike; +} + +export type StructValue> = + T extends StructDeserializer ? P : never; + +export type StructLike = StructSerializer & StructDeserializer; diff --git a/toolchain/side-effect-test/package.json b/toolchain/side-effect-test/package.json index b307028c..b5631d12 100644 --- a/toolchain/side-effect-test/package.json +++ b/toolchain/side-effect-test/package.json @@ -5,7 +5,8 @@ "description": "", "main": "index.js", "scripts": { - "start": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript --watch" + "start": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript --watch", + "test": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript" }, "keywords": [], "author": "", diff --git a/toolchain/side-effect-test/src/index.js b/toolchain/side-effect-test/src/index.js index d34ef8b8..af429790 100644 --- a/toolchain/side-effect-test/src/index.js +++ b/toolchain/side-effect-test/src/index.js @@ -1,8 +1,10 @@ import { bipedal, buffer, + concat, decodeUtf8, encodeUtf8, + extend, s16, s32, s64, @@ -15,7 +17,7 @@ import { u8, } from "@yume-chan/struct"; -bipedal(function () {}); +bipedal(function* () {}); buffer(u8); decodeUtf8(new Uint8Array()); encodeUtf8(""); @@ -28,7 +30,13 @@ u16(1); u32(1); u64(1); u8(1); -struct({}, {}); +struct({}, { littleEndian: true }); +concat( + { littleEndian: true }, + struct({ a: u8 }, { littleEndian: true }), + struct({ b: u8 }, { littleEndian: true }), +); +extend(struct({ a: u16 }, { littleEndian: true }), { b: buffer(32) }); export * from "@yume-chan/scrcpy"; export * from "@yume-chan/struct";