Offload functions to worker threads with shared memory primitives for Node.js.
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: string(n) variable-length UTF-8 string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+180 -11
+2
src/index.ts
··· 5 5 export type { Runner } from './runner.ts'; 6 6 export type { Loadable } from './shared/index.ts'; 7 7 export type { Bytes } from './shared/index.ts'; 8 + export type { SharedString } from './shared/index.ts'; 8 9 export { 9 10 Int8, 10 11 Uint8, ··· 38 39 int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, 39 40 mutex, rwlock, 40 41 bytes, 42 + string, 41 43 } from './shared/index.ts';
+18
src/shared/descriptors.ts
··· 1 1 import { Bytes } from './bytes.ts'; 2 + import { SharedString } from './string.ts'; 2 3 import { Int8 } from './int8.ts'; 3 4 import { Uint8 } from './uint8.ts'; 4 5 import { Int16 } from './int16.ts'; ··· 75 76 _size: size, 76 77 }) as BytesDescriptor; 77 78 } 79 + 80 + export interface StringDescriptor extends SharedString { 81 + byteSize: number; 82 + byteAlignment: number; 83 + _class: typeof SharedString; 84 + _maxBytes: number; 85 + } 86 + 87 + export function string(maxBytes: number): StringDescriptor { 88 + const instance = new SharedString(maxBytes); 89 + return Object.assign(instance, { 90 + byteSize: 4 + maxBytes, 91 + byteAlignment: SharedString.byteAlignment, 92 + _class: SharedString, 93 + _maxBytes: maxBytes, 94 + }) as StringDescriptor; 95 + }
+3 -1
src/shared/index.ts
··· 24 24 export { SharedStruct } from './shared-struct.ts'; 25 25 export { Tuple } from './tuple.ts'; 26 26 export { shared } from './shared.ts'; 27 + export { string } from './descriptors.ts'; 28 + export type { SharedString } from './string.ts'; 27 29 export { 28 30 int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, 29 31 int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, 30 32 mutex, rwlock, 31 33 bytes, 32 34 } from './descriptors.ts'; 33 - export type { Descriptor, BytesDescriptor } from './descriptors.ts'; 35 + export type { Descriptor, BytesDescriptor, StringDescriptor } from './descriptors.ts';
+7 -2
src/shared/reconstruct.ts
··· 1 1 import { SharedStruct } from './shared-struct.ts'; 2 2 import { Bytes } from './bytes.ts'; 3 + import { SharedString } from './string.ts'; 3 4 4 5 const SHARED = Symbol.for('moroutine.shared'); 5 6 ··· 19 20 } 20 21 return { __shared__: 'SharedStruct', fields: serializedFields }; 21 22 } 22 - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; 23 + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }), ...(data.maxBytes !== undefined && { maxBytes: data.maxBytes }) }; 23 24 } 24 25 return arg; 25 26 } ··· 32 33 } 33 34 return { __shared__: 'SharedStruct', fields: serializedFields }; 34 35 } 35 - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; 36 + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }), ...(data.maxBytes !== undefined && { maxBytes: data.maxBytes }) }; 36 37 } 37 38 38 39 export function deserializeArg(arg: unknown): unknown { ··· 41 42 if (data.__shared__ === 'Bytes') { 42 43 const typedData = data as { __shared__: string; buffer: SharedArrayBuffer; byteOffset: number; size: number }; 43 44 return new Bytes(typedData.size, typedData.buffer, typedData.byteOffset); 45 + } 46 + if (data.__shared__ === 'SharedString') { 47 + const typedData = data as { __shared__: string; buffer: SharedArrayBuffer; byteOffset: number; maxBytes: number }; 48 + return new SharedString(typedData.maxBytes, typedData.buffer, typedData.byteOffset); 44 49 } 45 50 if (data.__shared__ === 'SharedStruct') { 46 51 const fields = data.fields as Record<string, unknown>;
+40 -8
src/shared/shared.ts
··· 1 - import type { Descriptor, BytesDescriptor } from './descriptors.ts'; 1 + import type { Descriptor, BytesDescriptor, StringDescriptor } from './descriptors.ts'; 2 2 import { int32, int64, bool } from './descriptors.ts'; 3 3 import { Int32 } from './int32.ts'; 4 4 import { Int64 } from './int64.ts'; 5 5 import { Bool } from './bool.ts'; 6 6 import { Bytes } from './bytes.ts'; 7 + import { SharedString } from './string.ts'; 7 8 import { SharedStruct } from './shared-struct.ts'; 8 9 import { Tuple } from './tuple.ts'; 9 10 ··· 15 16 return typeof value === 'object' && value !== null && '_size' in value && '_class' in value; 16 17 } 17 18 19 + function isStringDescriptor(value: unknown): value is StringDescriptor { 20 + return typeof value === 'object' && value !== null && '_maxBytes' in value && '_class' in value; 21 + } 22 + 18 23 function isStructSchema(value: unknown): value is Record<string, unknown> { 19 24 return typeof value === 'object' && value !== null && !Array.isArray(value); 20 25 } ··· 46 51 offset: number; 47 52 initialValue?: unknown; 48 53 bytesSize?: number; 54 + stringMaxBytes?: number; 49 55 } 50 56 51 57 interface ArrayLeafEntry { ··· 54 60 offset: number; 55 61 initialValue?: unknown; 56 62 bytesSize?: number; 63 + stringMaxBytes?: number; 57 64 } 58 65 59 66 interface ArrayStructEntry { ··· 72 79 function collectLeaves(schema: Record<string, unknown>, path: string[], leaves: LeafEntry[], cursor: { offset: number }): void { 73 80 for (const key in schema) { 74 81 const value = schema[key]; 75 - if (isBytesDescriptor(value)) { 82 + if (isStringDescriptor(value)) { 83 + cursor.offset = align(cursor.offset, value.byteAlignment); 84 + leaves.push({ path: [...path, key], descriptor: value as unknown as Descriptor<unknown>, offset: cursor.offset, stringMaxBytes: value._maxBytes }); 85 + cursor.offset += value.byteSize; 86 + } else if (isBytesDescriptor(value)) { 76 87 cursor.offset = align(cursor.offset, value.byteAlignment); 77 88 leaves.push({ path: [...path, key], descriptor: value as unknown as Descriptor<unknown>, offset: cursor.offset, bytesSize: value._size }); 78 89 cursor.offset += value.byteSize; ··· 98 109 99 110 function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { offset: number }): void { 100 111 for (const element of schema) { 101 - if (isBytesDescriptor(element)) { 112 + if (isStringDescriptor(element)) { 113 + cursor.offset = align(cursor.offset, element.byteAlignment); 114 + leaves.push({ path: [], descriptor: element as unknown as Descriptor<unknown>, offset: cursor.offset, stringMaxBytes: element._maxBytes }); 115 + cursor.offset += element.byteSize; 116 + } else if (isBytesDescriptor(element)) { 102 117 cursor.offset = align(cursor.offset, element.byteAlignment); 103 118 leaves.push({ path: [], descriptor: element as unknown as Descriptor<unknown>, offset: cursor.offset, bytesSize: element._size }); 104 119 cursor.offset += element.byteSize; ··· 125 140 function processArraySchema(schema: unknown[], cursor: { offset: number }): { entries: ArrayEntry[] } { 126 141 const entries: ArrayEntry[] = []; 127 142 for (const element of schema) { 128 - if (isBytesDescriptor(element)) { 143 + if (isStringDescriptor(element)) { 144 + cursor.offset = align(cursor.offset, element.byteAlignment); 145 + entries.push({ type: 'leaf', descriptor: element as unknown as Descriptor<unknown>, offset: cursor.offset, stringMaxBytes: element._maxBytes }); 146 + cursor.offset += element.byteSize; 147 + } else if (isBytesDescriptor(element)) { 129 148 cursor.offset = align(cursor.offset, element.byteAlignment); 130 149 entries.push({ type: 'leaf', descriptor: element as unknown as Descriptor<unknown>, offset: cursor.offset, bytesSize: element._size }); 131 150 cursor.offset += element.byteSize; ··· 156 175 function buildTupleFromEntries(entries: ArrayEntry[], buffer: SharedArrayBuffer): Tuple<any> { 157 176 const elements = entries.map((entry) => { 158 177 if (entry.type === 'leaf') { 178 + if (entry.stringMaxBytes !== undefined) { 179 + return new SharedString(entry.stringMaxBytes, buffer, entry.offset); 180 + } 159 181 if (entry.bytesSize !== undefined) { 160 182 return new Bytes(entry.bytesSize, buffer, entry.offset); 161 183 } ··· 180 202 const fields: Record<string, any> = {}; 181 203 for (const key in schema) { 182 204 const value = schema[key]; 183 - if (isBytesDescriptor(value)) { 205 + if (isStringDescriptor(value)) { 206 + const leaf = leaves[leafIndex.i++]; 207 + fields[key] = new SharedString(leaf.stringMaxBytes!, buffer, leaf.offset); 208 + } else if (isBytesDescriptor(value)) { 184 209 const leaf = leaves[leafIndex.i++]; 185 210 fields[key] = new Bytes(leaf.bytesSize!, buffer, leaf.offset); 186 211 } else if (isDescriptor(value)) { ··· 206 231 function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): ArrayEntry[] { 207 232 const entries: ArrayEntry[] = []; 208 233 for (const element of schema) { 209 - if (isBytesDescriptor(element)) { 234 + if (isStringDescriptor(element)) { 235 + const leaf = leaves[leafIndex.i++]; 236 + entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset, stringMaxBytes: leaf.stringMaxBytes }); 237 + } else if (isBytesDescriptor(element)) { 210 238 const leaf = leaves[leafIndex.i++]; 211 239 entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset, bytesSize: leaf.bytesSize }); 212 240 } else if (isDescriptor(element)) { ··· 237 265 function countStructLeaves(schema: Record<string, unknown>, leaves: LeafEntry[], leafIndex: { i: number }): void { 238 266 for (const key in schema) { 239 267 const value = schema[key]; 240 - if (isBytesDescriptor(value) || isDescriptor(value)) { 268 + if (isStringDescriptor(value) || isBytesDescriptor(value) || isDescriptor(value)) { 241 269 leafIndex.i++; 242 270 } else if (Array.isArray(value)) { 243 271 countArrayLeaves(value, leaves, leafIndex); ··· 251 279 252 280 function countArrayLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): void { 253 281 for (const element of schema) { 254 - if (isBytesDescriptor(element) || isDescriptor(element)) { 282 + if (isStringDescriptor(element) || isBytesDescriptor(element) || isDescriptor(element)) { 255 283 leafIndex.i++; 256 284 } else if (Array.isArray(element)) { 257 285 countArrayLeaves(element, leaves, leafIndex); ··· 283 311 const instance = new Bool(new SharedArrayBuffer(Bool.byteSize), 0); 284 312 instance.store(schema); 285 313 return instance; 314 + } 315 + 316 + if (isStringDescriptor(schema)) { 317 + return schema; // already a standalone instance 286 318 } 287 319 288 320 if (isBytesDescriptor(schema)) {
+50
src/shared/string.ts
··· 1 + import type { Loadable } from './loadable.ts'; 2 + 3 + const SHARED = Symbol.for('moroutine.shared'); 4 + const encoder = new TextEncoder(); 5 + const decoder = new TextDecoder(); 6 + 7 + export class SharedString implements Loadable<string> { 8 + readonly maxBytes: number; 9 + private readonly lengthView: Uint32Array; 10 + private readonly dataView: Uint8Array; 11 + 12 + static byteAlignment = 4; 13 + 14 + constructor(maxBytes: number, buffer?: SharedArrayBuffer, byteOffset?: number) { 15 + this.maxBytes = maxBytes; 16 + const totalSize = 4 + maxBytes; 17 + const buf = buffer ?? new SharedArrayBuffer(totalSize); 18 + const offset = byteOffset ?? 0; 19 + this.lengthView = new Uint32Array(buf, offset, 1); 20 + this.dataView = new Uint8Array(buf, offset + 4, maxBytes); 21 + } 22 + 23 + load(): string { 24 + const len = this.lengthView[0]; 25 + if (len === 0) return ''; 26 + return decoder.decode(this.dataView.subarray(0, len)); 27 + } 28 + 29 + store(value: string): void { 30 + if (value === '') { 31 + this.lengthView[0] = 0; 32 + return; 33 + } 34 + const encoded = encoder.encode(value); 35 + if (encoded.length > this.maxBytes) { 36 + throw new RangeError(`Encoded string (${encoded.length} bytes) exceeds max ${this.maxBytes} bytes`); 37 + } 38 + this.dataView.set(encoded); 39 + this.lengthView[0] = encoded.length; 40 + } 41 + 42 + [SHARED](): { tag: string; buffer: SharedArrayBuffer; byteOffset: number; maxBytes: number } { 43 + return { 44 + tag: 'SharedString', 45 + buffer: this.lengthView.buffer as SharedArrayBuffer, 46 + byteOffset: this.lengthView.byteOffset, 47 + maxBytes: this.maxBytes, 48 + }; 49 + } 50 + }
+60
test/shared/string.test.ts
··· 1 + import { describe, it } from 'node:test'; 2 + import assert from 'node:assert/strict'; 3 + import { shared, string, int32 } from 'moroutine'; 4 + 5 + describe('string', () => { 6 + it('string(n) creates standalone empty string', () => { 7 + const s = string(32); 8 + assert.equal(s.load(), ''); 9 + }); 10 + 11 + it('shared(string(n)) creates string', () => { 12 + const s = shared(string(32)); 13 + assert.equal(s.load(), ''); 14 + }); 15 + 16 + it('store and load a string', () => { 17 + const s = string(32); 18 + s.store('hello'); 19 + assert.equal(s.load(), 'hello'); 20 + }); 21 + 22 + it('store overwrites previous value', () => { 23 + const s = string(32); 24 + s.store('hello'); 25 + s.store('world'); 26 + assert.equal(s.load(), 'world'); 27 + }); 28 + 29 + it('store empty string', () => { 30 + const s = string(32); 31 + s.store('hello'); 32 + s.store(''); 33 + assert.equal(s.load(), ''); 34 + }); 35 + 36 + it('store throws if encoded bytes exceed max', () => { 37 + const s = string(4); 38 + assert.throws(() => s.store('hello'), /exceeds/i); 39 + }); 40 + 41 + it('handles multibyte UTF-8', () => { 42 + const s = string(32); 43 + s.store('héllo'); 44 + assert.equal(s.load(), 'héllo'); 45 + }); 46 + 47 + it('string in struct schema', () => { 48 + const entity = shared({ name: string(16), hp: int32 }); 49 + entity.store({ name: 'goblin', hp: 50 }); 50 + assert.deepEqual(entity.load(), { name: 'goblin', hp: 50 }); 51 + }); 52 + 53 + it('string in struct shares one buffer', () => { 54 + const entity = shared({ name: string(16), hp: int32 }); 55 + const SHARED = Symbol.for('moroutine.shared'); 56 + const nameSer = (entity.fields.name as any)[SHARED](); 57 + const hpSer = (entity.fields.hp as any)[SHARED](); 58 + assert.equal(nameSer.buffer, hpSer.buffer); 59 + }); 60 + });