PORTS: Support all serializable data. (#1089)

A significant portion of players who use ports are passing objects through them. Currently they are required to handle that themselves via JSON serialization. This PR adds better support for passing objects; which is more convenient, extensive, and optimized (probably, more on this one later).

This adds zero overhead to existing (or when passing any primitive types) port usage, and also isn't a breaking change. The questions to debate here are:

Should objects be supported in the first place?
If so, how exactly do we want to serialize objects?
Based on an extensive discussion in Discord, the overwhelming majority answered "yes" to question one. As for question two, that has been much more hotly contested.

Ultimately, `structuredClone` was used, despite less-than-stellar performance, because other options were worse either in safety, speed, error-handling, or multiple of the above.
This commit is contained in:
LJ 2024-02-17 20:15:17 -07:00 committed by GitHub
parent aba2336093
commit 27a8abbdec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 33 additions and 50 deletions

@ -1326,12 +1326,6 @@ export const ns: InternalAPI<NSFull> = {
},
writePort: (ctx) => (_portNumber, data) => {
const portNumber = helpers.portNumber(ctx, _portNumber);
if (typeof data !== "string" && typeof data !== "number") {
throw helpers.makeRuntimeErrorMsg(
ctx,
`Trying to write invalid data to a port: only strings and numbers are valid.`,
);
}
return writePort(portNumber, data);
},
write: (ctx) => (_filename, _data, _mode) => {
@ -1364,12 +1358,6 @@ export const ns: InternalAPI<NSFull> = {
},
tryWritePort: (ctx) => (_portNumber, data) => {
const portNumber = helpers.portNumber(ctx, _portNumber);
if (typeof data !== "string" && typeof data !== "number") {
throw helpers.makeRuntimeErrorMsg(
ctx,
`Trying to write invalid data to a port: only strings and numbers are valid.`,
);
}
return tryWritePort(portNumber, data);
},
nextPortWrite: (ctx) => (_portNumber) => {

@ -3,12 +3,15 @@ import { NetscriptPort } from "@nsdefs";
import { NetscriptPorts } from "./NetscriptWorker";
import { PositiveInteger } from "./types";
type PortData = string | number;
type Resolver = () => void;
const emptyPortData = "NULL PORT DATA";
/** The object property is for typechecking and is not present at runtime */
export type PortNumber = PositiveInteger & { __PortNumber: true };
function isObjectLike(value: unknown): value is object {
return (typeof value === "object" && value !== null) || typeof value === "function";
}
/** Gets the numbered port, initializing it if it doesn't already exist.
* Only using for functions that write data/resolvers. Use NetscriptPorts.get(n) for */
export function getPort(n: PortNumber) {
@ -20,10 +23,11 @@ export function getPort(n: PortNumber) {
}
export class Port {
data: PortData[] = [];
data: any[] = [];
resolver: Resolver | null = null;
promise: Promise<void> | null = null;
resolve() {
add(data: any) {
this.data.push(data);
if (!this.resolver) return;
this.resolver();
this.resolver = null;
@ -43,44 +47,35 @@ export function portHandle(n: PortNumber): NetscriptPort {
};
}
export function writePort(n: PortNumber, value: unknown): PortData | null {
if (typeof value !== "number" && typeof value !== "string") {
throw new Error(
`port.write: Tried to write type ${typeof value}. Only string and number types may be written to ports.`,
);
}
export function writePort(n: PortNumber, value: unknown): any {
const port = getPort(n);
port.data.push(value);
port.resolve();
if (port.data.length > Settings.MaxPortCapacity) return port.data.shift() as PortData;
// Primitives don't need to be cloned.
port.add(isObjectLike(value) ? structuredClone(value) : value);
if (port.data.length > Settings.MaxPortCapacity) return port.data.shift();
return null;
}
export function tryWritePort(n: PortNumber, value: unknown): boolean {
if (typeof value != "number" && typeof value != "string") {
throw new Error(
`port.write: Tried to write type ${typeof value}. Only string and number types may be written to ports.`,
);
}
const port = getPort(n);
if (port.data.length >= Settings.MaxPortCapacity) return false;
port.data.push(value);
port.resolve();
// Primitives don't need to be cloned.
port.add(isObjectLike(value) ? structuredClone(value) : value);
return true;
}
export function readPort(n: PortNumber): PortData {
export function readPort(n: PortNumber): any {
const port = NetscriptPorts.get(n);
if (!port || !port.data.length) return emptyPortData;
const returnVal = port.data.shift() as PortData;
const returnVal = port.data.shift();
if (!port.data.length && !port.resolver) NetscriptPorts.delete(n);
return returnVal;
}
export function peekPort(n: PortNumber): PortData {
export function peekPort(n: PortNumber): any {
const port = NetscriptPorts.get(n);
if (!port || !port.data.length) return emptyPortData;
return port.data[0];
// Needed to avoid exposing internal objects.
return isObjectLike(port.data[0]) ? structuredClone(port.data[0]) : port.data[0];
}
export function nextPortWrite(n: PortNumber) {

@ -24,9 +24,6 @@ interface Skills {
*/
type CodingContractData = any;
/** @public */
type PortData = string | number;
/** @public */
type ScriptArg = string | number | boolean;
@ -1054,20 +1051,23 @@ export interface NetscriptPort {
* @remarks
* RAM cost: 0 GB
*
* @returns The data popped off the queue if it was full. */
write(value: string | number): PortData | null;
* @param value - Data to write, it's cloned with structuredClone().
* @returns The data popped off the queue if it was full.
*/
write(value: any): any;
/**
* Attempt to write data to the port.
* @remarks
* RAM cost: 0 GB
*
* @param value - Data to write, it's cloned with structuredClone().
* @returns True if the data was added to the port, false if the port was full
*/
tryWrite(value: string | number): boolean;
tryWrite(value: any): boolean;
/**
* Sleeps until the port is written to.
* Waits until the port is written to.
* @remarks
* RAM cost: 0 GB
*/
@ -1082,7 +1082,7 @@ export interface NetscriptPort {
* If the port is empty, then the string NULL PORT DATA will be returned.
* @returns the data read.
*/
read(): PortData;
read(): any;
/**
* Retrieve the first element from the port without removing it.
@ -1094,7 +1094,7 @@ export interface NetscriptPort {
* the port is empty, the string NULL PORT DATA will be returned.
* @returns the data read
*/
peek(): PortData;
peek(): any;
/**
* Check if the port is full.
@ -6642,10 +6642,10 @@ export interface NS {
* Otherwise, the data will be written normally.
*
* @param portNumber - Port to attempt to write to. Must be a positive integer.
* @param data - Data to write.
* @param data - Data to write, it's cloned with structuredClone().
* @returns True if the data is successfully written to the port, and false otherwise.
*/
tryWritePort(portNumber: number, data: string | number): boolean;
tryWritePort(portNumber: number, data: any): boolean;
/**
* Listen for a port write.
@ -6685,7 +6685,7 @@ export interface NS {
* @param portNumber - Port to peek. Must be a positive integer.
* @returns Data in the specified port.
*/
peek(portNumber: number): PortData;
peek(portNumber: number): any;
/**
* Clear data from a file.
@ -6716,10 +6716,10 @@ export interface NS {
*
* Write data to the given Netscript port.
* @param portNumber - Port to write to. Must be a positive integer.
* @param data - Data to write.
* @param data - Data to write, it's cloned with structuredClone().
* @returns The data popped off the queue if it was full, or null if it was not full.
*/
writePort(portNumber: number, data: string | number): PortData | null;
writePort(portNumber: number, data: any): any;
/**
* Read data from a port.
* @remarks
@ -6731,7 +6731,7 @@ export interface NS {
* @param portNumber - Port to read from. Must be a positive integer.
* @returns The data read.
*/
readPort(portNumber: number): PortData;
readPort(portNumber: number): any;
/**
* Get all data on a port.