diff --git a/src/index.ts b/src/index.ts index 24e6bc2..e057c6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { transfer } from './transfer.ts'; export type { Runner } from './runner.ts'; export type { Loadable } from './shared/index.ts'; export type { Bytes } from './shared/index.ts'; +export type { SharedString } from './shared/index.ts'; export { Int8, Uint8, @@ -38,4 +39,5 @@ export { int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, mutex, rwlock, bytes, + string, } from './shared/index.ts'; diff --git a/src/shared/descriptors.ts b/src/shared/descriptors.ts index 5530dc8..ba8f3bb 100644 --- a/src/shared/descriptors.ts +++ b/src/shared/descriptors.ts @@ -1,4 +1,5 @@ import { Bytes } from './bytes.ts'; +import { SharedString } from './string.ts'; import { Int8 } from './int8.ts'; import { Uint8 } from './uint8.ts'; import { Int16 } from './int16.ts'; @@ -75,3 +76,20 @@ export function bytes(size: number): BytesDescriptor { _size: size, }) as BytesDescriptor; } + +export interface StringDescriptor extends SharedString { + byteSize: number; + byteAlignment: number; + _class: typeof SharedString; + _maxBytes: number; +} + +export function string(maxBytes: number): StringDescriptor { + const instance = new SharedString(maxBytes); + return Object.assign(instance, { + byteSize: 4 + maxBytes, + byteAlignment: SharedString.byteAlignment, + _class: SharedString, + _maxBytes: maxBytes, + }) as StringDescriptor; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index cca9cab..80ba1db 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -24,10 +24,12 @@ export { slab } from './slab.ts'; export { SharedStruct } from './shared-struct.ts'; export { Tuple } from './tuple.ts'; export { shared } from './shared.ts'; +export { string } from './descriptors.ts'; +export type { SharedString } from './string.ts'; export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, mutex, rwlock, bytes, } from './descriptors.ts'; -export type { Descriptor, BytesDescriptor } from './descriptors.ts'; +export type { Descriptor, BytesDescriptor, StringDescriptor } from './descriptors.ts'; diff --git a/src/shared/reconstruct.ts b/src/shared/reconstruct.ts index 9a7ee8c..7bfc1f6 100644 --- a/src/shared/reconstruct.ts +++ b/src/shared/reconstruct.ts @@ -1,5 +1,6 @@ import { SharedStruct } from './shared-struct.ts'; import { Bytes } from './bytes.ts'; +import { SharedString } from './string.ts'; const SHARED = Symbol.for('moroutine.shared'); @@ -19,7 +20,7 @@ export function serializeArg(arg: unknown): unknown { } return { __shared__: 'SharedStruct', fields: serializedFields }; } - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }), ...(data.maxBytes !== undefined && { maxBytes: data.maxBytes }) }; } return arg; } @@ -32,7 +33,7 @@ function serializeStructField(data: { tag: string; [key: string]: unknown }): un } return { __shared__: 'SharedStruct', fields: serializedFields }; } - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }), ...(data.maxBytes !== undefined && { maxBytes: data.maxBytes }) }; } export function deserializeArg(arg: unknown): unknown { @@ -42,6 +43,10 @@ export function deserializeArg(arg: unknown): unknown { const typedData = data as { __shared__: string; buffer: SharedArrayBuffer; byteOffset: number; size: number }; return new Bytes(typedData.size, typedData.buffer, typedData.byteOffset); } + if (data.__shared__ === 'SharedString') { + const typedData = data as { __shared__: string; buffer: SharedArrayBuffer; byteOffset: number; maxBytes: number }; + return new SharedString(typedData.maxBytes, typedData.buffer, typedData.byteOffset); + } if (data.__shared__ === 'SharedStruct') { const fields = data.fields as Record; const reconstructed: Record = {}; diff --git a/src/shared/shared.ts b/src/shared/shared.ts index dec631d..8a30e17 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,9 +1,10 @@ -import type { Descriptor, BytesDescriptor } from './descriptors.ts'; +import type { Descriptor, BytesDescriptor, StringDescriptor } from './descriptors.ts'; import { int32, int64, bool } from './descriptors.ts'; import { Int32 } from './int32.ts'; import { Int64 } from './int64.ts'; import { Bool } from './bool.ts'; import { Bytes } from './bytes.ts'; +import { SharedString } from './string.ts'; import { SharedStruct } from './shared-struct.ts'; import { Tuple } from './tuple.ts'; @@ -15,6 +16,10 @@ function isBytesDescriptor(value: unknown): value is BytesDescriptor { return typeof value === 'object' && value !== null && '_size' in value && '_class' in value; } +function isStringDescriptor(value: unknown): value is StringDescriptor { + return typeof value === 'object' && value !== null && '_maxBytes' in value && '_class' in value; +} + function isStructSchema(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -46,6 +51,7 @@ interface LeafEntry { offset: number; initialValue?: unknown; bytesSize?: number; + stringMaxBytes?: number; } interface ArrayLeafEntry { @@ -54,6 +60,7 @@ interface ArrayLeafEntry { offset: number; initialValue?: unknown; bytesSize?: number; + stringMaxBytes?: number; } interface ArrayStructEntry { @@ -72,7 +79,11 @@ type ArrayEntry = ArrayLeafEntry | ArrayStructEntry | ArrayTupleEntry; function collectLeaves(schema: Record, path: string[], leaves: LeafEntry[], cursor: { offset: number }): void { for (const key in schema) { const value = schema[key]; - if (isBytesDescriptor(value)) { + if (isStringDescriptor(value)) { + cursor.offset = align(cursor.offset, value.byteAlignment); + leaves.push({ path: [...path, key], descriptor: value as unknown as Descriptor, offset: cursor.offset, stringMaxBytes: value._maxBytes }); + cursor.offset += value.byteSize; + } else if (isBytesDescriptor(value)) { cursor.offset = align(cursor.offset, value.byteAlignment); leaves.push({ path: [...path, key], descriptor: value as unknown as Descriptor, offset: cursor.offset, bytesSize: value._size }); cursor.offset += value.byteSize; @@ -98,7 +109,11 @@ function collectLeaves(schema: Record, path: string[], leaves: function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { offset: number }): void { for (const element of schema) { - if (isBytesDescriptor(element)) { + if (isStringDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + leaves.push({ path: [], descriptor: element as unknown as Descriptor, offset: cursor.offset, stringMaxBytes: element._maxBytes }); + cursor.offset += element.byteSize; + } else if (isBytesDescriptor(element)) { cursor.offset = align(cursor.offset, element.byteAlignment); leaves.push({ path: [], descriptor: element as unknown as Descriptor, offset: cursor.offset, bytesSize: element._size }); cursor.offset += element.byteSize; @@ -125,7 +140,11 @@ function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { of function processArraySchema(schema: unknown[], cursor: { offset: number }): { entries: ArrayEntry[] } { const entries: ArrayEntry[] = []; for (const element of schema) { - if (isBytesDescriptor(element)) { + if (isStringDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + entries.push({ type: 'leaf', descriptor: element as unknown as Descriptor, offset: cursor.offset, stringMaxBytes: element._maxBytes }); + cursor.offset += element.byteSize; + } else if (isBytesDescriptor(element)) { cursor.offset = align(cursor.offset, element.byteAlignment); entries.push({ type: 'leaf', descriptor: element as unknown as Descriptor, offset: cursor.offset, bytesSize: element._size }); cursor.offset += element.byteSize; @@ -156,6 +175,9 @@ function processArraySchema(schema: unknown[], cursor: { offset: number }): { en function buildTupleFromEntries(entries: ArrayEntry[], buffer: SharedArrayBuffer): Tuple { const elements = entries.map((entry) => { if (entry.type === 'leaf') { + if (entry.stringMaxBytes !== undefined) { + return new SharedString(entry.stringMaxBytes, buffer, entry.offset); + } if (entry.bytesSize !== undefined) { return new Bytes(entry.bytesSize, buffer, entry.offset); } @@ -180,7 +202,10 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b const fields: Record = {}; for (const key in schema) { const value = schema[key]; - if (isBytesDescriptor(value)) { + if (isStringDescriptor(value)) { + const leaf = leaves[leafIndex.i++]; + fields[key] = new SharedString(leaf.stringMaxBytes!, buffer, leaf.offset); + } else if (isBytesDescriptor(value)) { const leaf = leaves[leafIndex.i++]; fields[key] = new Bytes(leaf.bytesSize!, buffer, leaf.offset); } else if (isDescriptor(value)) { @@ -206,7 +231,10 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): ArrayEntry[] { const entries: ArrayEntry[] = []; for (const element of schema) { - if (isBytesDescriptor(element)) { + if (isStringDescriptor(element)) { + const leaf = leaves[leafIndex.i++]; + entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset, stringMaxBytes: leaf.stringMaxBytes }); + } else if (isBytesDescriptor(element)) { const leaf = leaves[leafIndex.i++]; entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset, bytesSize: leaf.bytesSize }); } else if (isDescriptor(element)) { @@ -237,7 +265,7 @@ function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], le function countStructLeaves(schema: Record, leaves: LeafEntry[], leafIndex: { i: number }): void { for (const key in schema) { const value = schema[key]; - if (isBytesDescriptor(value) || isDescriptor(value)) { + if (isStringDescriptor(value) || isBytesDescriptor(value) || isDescriptor(value)) { leafIndex.i++; } else if (Array.isArray(value)) { countArrayLeaves(value, leaves, leafIndex); @@ -251,7 +279,7 @@ function countStructLeaves(schema: Record, leaves: LeafEntry[], function countArrayLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): void { for (const element of schema) { - if (isBytesDescriptor(element) || isDescriptor(element)) { + if (isStringDescriptor(element) || isBytesDescriptor(element) || isDescriptor(element)) { leafIndex.i++; } else if (Array.isArray(element)) { countArrayLeaves(element, leaves, leafIndex); @@ -285,6 +313,10 @@ export function shared(schema: unknown): any { return instance; } + if (isStringDescriptor(schema)) { + return schema; // already a standalone instance + } + if (isBytesDescriptor(schema)) { return schema; // already a standalone instance } diff --git a/src/shared/string.ts b/src/shared/string.ts new file mode 100644 index 0000000..b5c5cd6 --- /dev/null +++ b/src/shared/string.ts @@ -0,0 +1,50 @@ +import type { Loadable } from './loadable.ts'; + +const SHARED = Symbol.for('moroutine.shared'); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export class SharedString implements Loadable { + readonly maxBytes: number; + private readonly lengthView: Uint32Array; + private readonly dataView: Uint8Array; + + static byteAlignment = 4; + + constructor(maxBytes: number, buffer?: SharedArrayBuffer, byteOffset?: number) { + this.maxBytes = maxBytes; + const totalSize = 4 + maxBytes; + const buf = buffer ?? new SharedArrayBuffer(totalSize); + const offset = byteOffset ?? 0; + this.lengthView = new Uint32Array(buf, offset, 1); + this.dataView = new Uint8Array(buf, offset + 4, maxBytes); + } + + load(): string { + const len = this.lengthView[0]; + if (len === 0) return ''; + return decoder.decode(this.dataView.subarray(0, len)); + } + + store(value: string): void { + if (value === '') { + this.lengthView[0] = 0; + return; + } + const encoded = encoder.encode(value); + if (encoded.length > this.maxBytes) { + throw new RangeError(`Encoded string (${encoded.length} bytes) exceeds max ${this.maxBytes} bytes`); + } + this.dataView.set(encoded); + this.lengthView[0] = encoded.length; + } + + [SHARED](): { tag: string; buffer: SharedArrayBuffer; byteOffset: number; maxBytes: number } { + return { + tag: 'SharedString', + buffer: this.lengthView.buffer as SharedArrayBuffer, + byteOffset: this.lengthView.byteOffset, + maxBytes: this.maxBytes, + }; + } +} diff --git a/test/shared/string.test.ts b/test/shared/string.test.ts new file mode 100644 index 0000000..c5eec76 --- /dev/null +++ b/test/shared/string.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { shared, string, int32 } from 'moroutine'; + +describe('string', () => { + it('string(n) creates standalone empty string', () => { + const s = string(32); + assert.equal(s.load(), ''); + }); + + it('shared(string(n)) creates string', () => { + const s = shared(string(32)); + assert.equal(s.load(), ''); + }); + + it('store and load a string', () => { + const s = string(32); + s.store('hello'); + assert.equal(s.load(), 'hello'); + }); + + it('store overwrites previous value', () => { + const s = string(32); + s.store('hello'); + s.store('world'); + assert.equal(s.load(), 'world'); + }); + + it('store empty string', () => { + const s = string(32); + s.store('hello'); + s.store(''); + assert.equal(s.load(), ''); + }); + + it('store throws if encoded bytes exceed max', () => { + const s = string(4); + assert.throws(() => s.store('hello'), /exceeds/i); + }); + + it('handles multibyte UTF-8', () => { + const s = string(32); + s.store('héllo'); + assert.equal(s.load(), 'héllo'); + }); + + it('string in struct schema', () => { + const entity = shared({ name: string(16), hp: int32 }); + entity.store({ name: 'goblin', hp: 50 }); + assert.deepEqual(entity.load(), { name: 'goblin', hp: 50 }); + }); + + it('string in struct shares one buffer', () => { + const entity = shared({ name: string(16), hp: int32 }); + const SHARED = Symbol.for('moroutine.shared'); + const nameSer = (entity.fields.name as any)[SHARED](); + const hpSer = (entity.fields.hp as any)[SHARED](); + assert.equal(nameSer.buffer, hpSer.buffer); + }); +});