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) => { writePort: (ctx) => (_portNumber, data) => {
const portNumber = helpers.portNumber(ctx, _portNumber); 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); return writePort(portNumber, data);
}, },
write: (ctx) => (_filename, _data, _mode) => { write: (ctx) => (_filename, _data, _mode) => {
@ -1364,12 +1358,6 @@ export const ns: InternalAPI<NSFull> = {
}, },
tryWritePort: (ctx) => (_portNumber, data) => { tryWritePort: (ctx) => (_portNumber, data) => {
const portNumber = helpers.portNumber(ctx, _portNumber); 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); return tryWritePort(portNumber, data);
}, },
nextPortWrite: (ctx) => (_portNumber) => { nextPortWrite: (ctx) => (_portNumber) => {

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

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