diff --git a/build/wd_test.bzl b/build/wd_test.bzl index f18981e6789..76c96247b28 100644 --- a/build/wd_test.bzl +++ b/build/wd_test.bzl @@ -65,30 +65,71 @@ def _wd_test_impl(ctx): if is_windows: # Batch script executables must end with ".bat" executable = ctx.actions.declare_file("%s_wd_test.bat" % ctx.label.name) - ctx.actions.write( - output = executable, - # PowerShell correctly handles forward slashes in executable paths generated by Bazel (e.g. "bazel-bin/src/workerd/server/workerd.exe") - content = "powershell -Command \"%*\" `-dTEST_TMPDIR=$ENV:TEST_TMPDIR\r\n", - is_executable = True, - ) + content = """ +@echo off +setlocal EnableDelayedExpansion + +REM Start sidecar if specified +if not "%SIDECAR%" == "" ( + start /b "" "%SIDECAR%" > nul 2>&1 + set SIDECAR_PID=!ERRORLEVEL! + timeout /t 1 > nul +) + +REM Run the actual test +powershell -Command \"%*\" `-dTEST_TMPDIR=$ENV:TEST_TMPDIR +set TEST_EXIT=!ERRORLEVEL! + +REM Cleanup sidecar if it was started +if defined SIDECAR_PID ( + taskkill /F /PID !SIDECAR_PID! > nul 2>&1 +) + +exit /b !TEST_EXIT! +""".replace("$(SIDECAR)", ctx.file.sidecar.path if ctx.file.sidecar else "") else: executable = ctx.outputs.executable - ctx.actions.write( - output = executable, - content = """ - #! /bin/sh - echo - echo \\(cd `pwd` \\&\\& \"$@\" -dTEST_TMPDIR=$TEST_TMPDIR\\) - echo - exec \"$@\" -dTEST_TMPDIR=$TEST_TMPDIR - """, - is_executable = True, - ) + content = """#!/bin/sh +set -e + +cleanup() { + if [ ! -z "$SIDECAR_PID" ]; then + kill $SIDECAR_PID 2>/dev/null || true + fi +} + +trap cleanup EXIT + +# Start sidecar if specified +if [ ! -z "$(SIDECAR)" ]; then + "$(SIDECAR)" & SIDECAR_PID=$! + # Wait until the process is ready + sleep 3 +fi + +# Run the actual test +"$@" -dTEST_TMPDIR=$TEST_TMPDIR +""".replace("$(SIDECAR)", ctx.file.sidecar.short_path if ctx.file.sidecar else "") + + ctx.actions.write( + output = executable, + content = content, + is_executable = True, + ) + + runfiles = ctx.runfiles(files = ctx.files.data) + if ctx.file.sidecar: + runfiles = runfiles.merge(ctx.runfiles(files = [ctx.file.sidecar])) + + # Also merge the sidecar's own runfiles if it has any + default_runfiles = ctx.attr.sidecar[DefaultInfo].default_runfiles + if default_runfiles: + runfiles = runfiles.merge(default_runfiles) return [ DefaultInfo( executable = executable, - runfiles = ctx.runfiles(files = ctx.files.data), + runfiles = runfiles, ), ] @@ -104,6 +145,11 @@ _wd_test = rule( ), "flags": attr.string_list(), "data": attr.label_list(allow_files = True), + "sidecar": attr.label( + allow_single_file = True, + executable = True, + cfg = "exec", + ), "_platforms_os_windows": attr.label(default = "@platforms//os:windows"), }, ) diff --git a/src/node/internal/internal_errors.ts b/src/node/internal/internal_errors.ts index 3320ac35993..6751ab1c6bf 100644 --- a/src/node/internal/internal_errors.ts +++ b/src/node/internal/internal_errors.ts @@ -609,6 +609,55 @@ export class DnsError extends NodeError { } } +export class ERR_OPTION_NOT_IMPLEMENTED extends NodeError { + constructor(name: string | symbol) { + if (typeof name === 'symbol') { + name = (name as symbol).description!; + } + super( + 'ERR_OPTION_NOT_IMPLEMENTED', + `The ${name} option is not implemented` + ); + } +} + +export class ERR_SOCKET_BAD_PORT extends NodeError { + constructor(name: string, port: any, allowZero: boolean) { + const operator = allowZero ? '>=' : '>'; + super( + 'ERR_SOCKET_BAD_PORT', + `${name} should be ${operator} 0 and < 65536. Received ${typeof port}.` + ); + } +} + +export class EPIPE extends NodeError { + constructor() { + super('EPIPE', 'This socket has been ended by the other party'); + } +} + +export class ERR_SOCKET_CLOSED_BEFORE_CONNECTION extends NodeError { + constructor() { + super( + 'ERR_SOCKET_CLOSED_BEFORE_CONNETION', + 'Socket closed before connection established' + ); + } +} + +export class ERR_SOCKET_CLOSED extends NodeError { + constructor() { + super('ERR_SOCKET_CLOSED', 'Socket is closed'); + } +} + +export class ERR_SOCKET_CONNECTING extends NodeError { + constructor() { + super('ERR_SOCKET_CONNECTING', 'Socket is already connecting'); + } +} + export function aggregateTwoErrors(innerError: any, outerError: any) { if (innerError && outerError && innerError !== outerError) { if (Array.isArray(outerError.errors)) { diff --git a/src/node/internal/sockets.d.ts b/src/node/internal/sockets.d.ts new file mode 100644 index 00000000000..91bacf8bae2 --- /dev/null +++ b/src/node/internal/sockets.d.ts @@ -0,0 +1,25 @@ +import type { Buffer } from 'node-internal:internal_buffer'; + +declare namespace sockets { + function connect( + input: string, + options: Record + ): { + opened: Promise; + closed: Promise; + close(): Promise; + readable: { + getReader(options: Record): { + close(): Promise; + read(value: unknown): Promise<{ value: Buffer; done: boolean }>; + }; + }; + writable: { + getWriter(): { + close(): Promise; + write(data: string | ArrayBufferView): Promise; + }; + }; + }; +} +export default sockets; diff --git a/src/node/internal/streams_duplex.d.ts b/src/node/internal/streams_duplex.d.ts new file mode 100644 index 00000000000..661b010cc53 --- /dev/null +++ b/src/node/internal/streams_duplex.d.ts @@ -0,0 +1,7 @@ +export { + Duplex, + Writable, + WritableOptions, + Readable, + ReadableOptions, +} from 'node:stream'; diff --git a/src/node/internal/streams_util.d.ts b/src/node/internal/streams_util.d.ts index 8f3a8f7ee19..785bb584ef7 100644 --- a/src/node/internal/streams_util.d.ts +++ b/src/node/internal/streams_util.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import type { FinishedOptions } from 'node:stream'; type FinishedStream = diff --git a/src/node/internal/validators.ts b/src/node/internal/validators.ts index 89ff267578d..ae9cd5ed44a 100644 --- a/src/node/internal/validators.ts +++ b/src/node/internal/validators.ts @@ -30,11 +30,12 @@ import { normalizeEncoding } from 'node-internal:internal_utils'; import { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, + ERR_SOCKET_BAD_PORT, ERR_OUT_OF_RANGE, } from 'node-internal:internal_errors'; import { default as bufferUtil } from 'node-internal:buffer'; -// TODO(someday): Not current implementing parseFileMode, validatePort +// TODO(someday): Not current implementing parseFileMode export function isInt32(value: unknown): value is number { // @ts-expect-error Due to value being unknown @@ -301,6 +302,23 @@ export function checkRangesOrGetDefault( return number; } +export function validatePort( + port: unknown, + name = 'Port', + allowZero = true +): number { + if ( + (typeof port !== 'number' && typeof port !== 'string') || + (typeof port === 'string' && port.trim().length === 0) || + +port !== +port >>> 0 || + +port > 0xffff || + (port === 0 && !allowZero) + ) { + throw new ERR_SOCKET_BAD_PORT(name, port, allowZero); + } + return +port | 0; +} + export default { isInt32, isUint32, @@ -316,6 +334,7 @@ export default { validateOneOf, validateString, validateUint32, + validatePort, // Zlib specific checkFiniteNumber, diff --git a/src/node/internal/zlib.d.ts b/src/node/internal/zlib.d.ts index 879ac9b4a9e..0f0190d4b7a 100644 --- a/src/node/internal/zlib.d.ts +++ b/src/node/internal/zlib.d.ts @@ -184,7 +184,6 @@ export abstract class CompressionStream { public [owner_symbol]: Zlib; // Not used by C++ implementation but required to be Node.js compatible. public inOff: number; - /* eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents */ public buffer: NodeJS.TypedArray | null; public cb: () => void; public availOutBefore: number; diff --git a/src/node/net.ts b/src/node/net.ts new file mode 100644 index 00000000000..89bfab07b41 --- /dev/null +++ b/src/node/net.ts @@ -0,0 +1,1399 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import inner from 'cloudflare-internal:sockets'; + +import { + ERR_INVALID_ARG_VALUE, + ERR_INVALID_ARG_TYPE, + ERR_MISSING_ARGS, + ERR_OUT_OF_RANGE, + ERR_OPTION_NOT_IMPLEMENTED, + ERR_SOCKET_CLOSED, + ERR_SOCKET_CLOSED_BEFORE_CONNECTION, + ERR_SOCKET_CONNECTING, + EPIPE, +} from 'node-internal:internal_errors'; + +import { + validateAbortSignal, + validateFunction, + validateInt32, + validateNumber, + validatePort, +} from 'node-internal:validators'; + +import { isUint8Array, isArrayBufferView } from 'node-internal:internal_types'; +import { Duplex } from 'node-internal:streams_duplex'; +import { Buffer } from 'node-internal:internal_buffer'; + +const kLastWriteQueueSize = Symbol('kLastWriteQueueSize'); +const kTimeout = Symbol('kTimeout'); +const kBuffer = Symbol('kBuffer'); +const kBufferCb = Symbol('kBufferCb'); +const kBufferGen = Symbol('kBufferGen'); +const kBytesRead = Symbol('kBytesRead'); +const kBytesWritten = Symbol('kBytesWritten'); +const kUpdateTimer = Symbol('kUpdateTimer'); +const normalizedArgsSymbol = Symbol('normalizedArgs'); + +// Once the socket has been opened, the socket info provided by the +// socket.opened promise will be stored here. +const kSocketInfo = Symbol('kSocketInfo'); + +// IPv4 Segment +const v4Seg = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])'; +const v4Str = `(?:${v4Seg}\\.){3}${v4Seg}`; +const IPv4Reg = new RegExp(`^${v4Str}$`); + +// IPv6 Segment +const v6Seg = '(?:[0-9a-fA-F]{1,4})'; +const IPv6Reg = new RegExp( + '^(?:' + + `(?:${v6Seg}:){7}(?:${v6Seg}|:)|` + + `(?:${v6Seg}:){6}(?:${v4Str}|:${v6Seg}|:)|` + + `(?:${v6Seg}:){5}(?::${v4Str}|(?::${v6Seg}){1,2}|:)|` + + `(?:${v6Seg}:){4}(?:(?::${v6Seg}){0,1}:${v4Str}|(?::${v6Seg}){1,3}|:)|` + + `(?:${v6Seg}:){3}(?:(?::${v6Seg}){0,2}:${v4Str}|(?::${v6Seg}){1,4}|:)|` + + `(?:${v6Seg}:){2}(?:(?::${v6Seg}){0,3}:${v4Str}|(?::${v6Seg}){1,5}|:)|` + + `(?:${v6Seg}:){1}(?:(?::${v6Seg}){0,4}:${v4Str}|(?::${v6Seg}){1,6}|:)|` + + `(?::(?:(?::${v6Seg}){0,5}:${v4Str}|(?::${v6Seg}){1,7}|:))` + + ')(?:%[0-9a-zA-Z-.:]{1,})?$' +); + +const TIMEOUT_MAX = 2 ** 31 - 1; + +// ====================================================================================== + +type SocketOptions = { + timeout?: number; + writable?: boolean; + readable?: boolean; + decodeStrings?: boolean; + autoDestroy?: boolean; + objectMode?: boolean; + readableObjectMode?: boolean; + writableObjectMode?: boolean; + keepAliveInitialDelay?: number; + fd?: number; + handle?: VoidFunction; + noDelay?: boolean; + keepAlive?: boolean; + allowHalfOpen?: boolean; + emitClose?: boolean; + signal?: AbortSignal; + onread?: { callback?: () => Uint8Array; buffer?: Uint8Array }; +}; + +import type { + IpcSocketConnectOpts, + Socket as SocketType, + SocketConnectOpts, + TcpSocketConnectOpts, + AddressInfo, +} from 'node:net'; + +type SocketClass = SocketType & { + timeout: number; + connecting: boolean; + _aborted: boolean; + _hadError: boolean; + _parent: null | SocketClass; + _host: null | string; + _peername: null | string; + _getsockname(): + | {} + | { + address?: string; + port?: number; + family?: string; + }; + [kLastWriteQueueSize]: number | null | undefined; + [kTimeout]: SocketClass | null | undefined; + [kBuffer]: null | boolean | Uint8Array; + [kBufferCb]: + | null + | undefined + | ((len?: number, buf?: Buffer) => boolean | Uint8Array); + [kBufferGen]: null | (() => null | boolean | Uint8Array); + [kSocketInfo]: null | { + address?: string; + port?: number; + family?: number | string; + remoteAddress?: Record; + }; + [kBytesRead]: number; + [kBytesWritten]: number; + _closeAfterHandlingError: boolean; + _handle: null | { + writeQueueSize?: number; + lastWriteQueueSize?: number; + reading?: boolean; + bytesRead: number; + bytesWritten: number; + socket: { close(): Promise }; + reader: { + close(): Promise; + read(value: unknown): Promise<{ value: Buffer; done: boolean }>; + }; + writer: { + close(): Promise; + write(data: string | ArrayBufferView): Promise; + }; + }; + _sockname?: null | AddressInfo; + _onTimeout(): void; + _unrefTimer(): void; + _writeGeneric( + writev: boolean, + data: { chunk: string | ArrayBufferView; encoding: string }[], + encoding: string, + cb: (err?: Error) => void + ): void; + _final(cb: (err?: Error) => void): void; + _read(n: number): void; + _reset(): void; + _getpeername(): Record; + _writableState: null | unknown[]; + + new (): SocketClass; + prototype: SocketClass; +}; + +export const Socket = function Socket( + this: SocketClass, + options?: SocketOptions +) { + if (!(this instanceof Socket)) { + // @ts-expect-error TS7009 Required due to types + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return new Socket(options); + } + + if (options?.objectMode) { + throw new ERR_INVALID_ARG_VALUE( + 'options.objectMode', + options.objectMode, + 'is not supported' + ); + } else if (options?.readableObjectMode || options?.writableObjectMode) { + throw new ERR_INVALID_ARG_VALUE( + `options.${ + options.readableObjectMode ? 'readableObjectMode' : 'writableObjectMode' + }`, + options.readableObjectMode || options.writableObjectMode, + 'is not supported' + ); + } + if (typeof options?.keepAliveInitialDelay !== 'undefined') { + validateNumber( + options.keepAliveInitialDelay, + 'options.keepAliveInitialDelay' + ); + + if (options.keepAliveInitialDelay < 0) { + options.keepAliveInitialDelay = 0; + } + } + + if (typeof options === 'number') { + options = { fd: options as number }; + } else { + options = { ...options }; + } + + if (options.handle) { + // We are not supporting the options.handle option for now. This is the + // option that allows users to pass in a handle to an existing socket. + throw new ERR_OPTION_NOT_IMPLEMENTED('options.handle'); + } else if (options.fd !== undefined) { + // We are not supporting the options.fd option for now. This is the option + // that allows users to pass in a file descriptor to an existing socket. + // Workers doesn't have file descriptors and does not use them in any way. + throw new ERR_OPTION_NOT_IMPLEMENTED('options.fd'); + } + + if (options.noDelay) { + throw new ERR_OPTION_NOT_IMPLEMENTED('truthy options.noDelay'); + } + if (options.keepAlive) { + throw new ERR_OPTION_NOT_IMPLEMENTED('truthy options.keepAlive'); + } + + options.allowHalfOpen = Boolean(options.allowHalfOpen); + // TODO(now): Match behavior with Node.js + // In Node.js, emitClose and autoDestroy are false by default so that + // the socket must handle those itself, including emitting the close + // event with the hadError argument. We should match that behavior. + options.emitClose = false; + options.autoDestroy = true; + options.decodeStrings = false; + + // In Node.js, these are meaningful when the options.fd is used. + // We do not support options.fd so we just ignore whatever value + // is given and always pass true. + options.readable = true; + options.writable = true; + + this.connecting = false; + this._hadError = false; + this._parent = null; + this._host = null; + this[kLastWriteQueueSize] = 0; + this[kTimeout] = null; + this[kBuffer] = null; + this[kBufferCb] = null; + this[kBufferGen] = null; + this[kSocketInfo] = null; + this[kBytesRead] = 0; + this[kBytesWritten] = 0; + this._closeAfterHandlingError = false; + // @ts-expect-error TS2540 Required due to types + this.autoSelectFamilyAttemptedAddresses = []; + + Duplex.call(this, options); + + this.once('end', onReadableStreamEnd); + + if (options.signal) { + addClientAbortSignalOption(this, options); + } + + const onread = options.onread; + if ( + onread != null && + typeof onread === 'object' && + // The onread.buffer can either be a Uint8Array or a function that returns + // a Uint8Array. + (isUint8Array(onread.buffer) || typeof onread.buffer === 'function') && + // The onread.callback is the function used to deliver the read buffer to + // the application. + typeof onread.callback === 'function' + ) { + if (typeof onread.buffer === 'function') { + this[kBuffer] = true; + this[kBufferGen] = onread.buffer; + } else { + this[kBuffer] = onread.buffer; + this[kBufferGen] = (): Uint8Array | boolean | null => this[kBuffer]; + } + this[kBufferCb] = onread.callback; + } else { + this[kBuffer] = true; + this[kBufferGen] = (): Uint8Array => new Uint8Array(4096); + this[kBufferCb] = undefined; + } +} as unknown as SocketClass; + +Object.setPrototypeOf(Socket.prototype, Duplex.prototype); +Object.setPrototypeOf(Socket, Duplex); + +Socket.prototype._unrefTimer = function _unrefTimer( + this: SocketClass | null +): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + for (let s = this; s != null; s = s._parent) { + if (s[kTimeout] != null) { + clearTimeout(s[kTimeout] as unknown as number); + s[kTimeout] = (this as SocketClass).setTimeout( + s.timeout, + s._onTimeout.bind(s) + ); + } + } +}; + +Socket.prototype.setTimeout = function ( + this: SocketClass, + msecs: number, + callback?: () => void +): SocketClass { + if (this.destroyed) return this; + + this.timeout = msecs; + + msecs = getTimerDuration(msecs, 'msecs'); + + clearTimeout(this[kTimeout] as unknown as number); + + if (msecs === 0) { + if (callback !== undefined) { + validateFunction(callback, 'callback'); + this.removeListener('timeout', callback); + } + } else { + // @ts-expect-error TS2740 Required to not overcomplicate types + this[kTimeout] = setTimeout(this._onTimeout.bind(this), msecs); + if (callback !== undefined) { + validateFunction(callback, 'callback'); + this.once('timeout', callback); + } + } + return this; +}; + +Socket.prototype._onTimeout = function (this: SocketClass): void { + const handle = this._handle; + const lastWriteQueueSize = this[kLastWriteQueueSize] as number; + if (lastWriteQueueSize > 0 && handle) { + // `lastWriteQueueSize !== writeQueueSize` means there is + // an active write in progress, so we suppress the timeout. + const { writeQueueSize } = handle; + if (lastWriteQueueSize !== writeQueueSize) { + this[kLastWriteQueueSize] = writeQueueSize; + this._unrefTimer(); + return; + } + } + this.emit('timeout'); +}; + +Socket.prototype._getpeername = function ( + this: SocketClass +): Record { + if (this._handle == null) { + return {}; + } else { + this[kSocketInfo] ??= {}; + return { ...this[kSocketInfo].remoteAddress }; + } +}; + +Socket.prototype._getsockname = function (this: SocketClass): AddressInfo | {} { + if (this._handle == null) { + return {}; + } + this._sockname ??= { + address: '0.0.0.0', + port: 0, + family: 'IPv4', + }; + return this._sockname; +}; + +Socket.prototype.address = function (this: SocketClass): {} | AddressInfo { + if (this.destroyed) return {}; + return this._getsockname(); +}; + +// ====================================================================================== +// Writable side ... + +Socket.prototype._writeGeneric = function ( + this: SocketClass, + writev: boolean, + data: { chunk: string | ArrayBufferView; encoding: string }[], + encoding: string, + cb: (err?: Error) => void + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type +): false | void { + // If we are still connecting, buffer this for later. + // The writable logic will buffer up any more writes while + // waiting for this one to be done. + try { + if (this.connecting) { + function onClose(): void { + cb(new ERR_SOCKET_CLOSED_BEFORE_CONNECTION()); + } + this.once('connect', () => { + // Note that off is a Node.js equivalent to removeEventListener + this.off('close', onClose); + this._writeGeneric(writev, data, encoding, cb); + }); + this.once('close', onClose); + return; + } + + if (this._handle?.writer === undefined) { + cb(new ERR_SOCKET_CLOSED()); + return false; + } + + this._unrefTimer(); + + let lastWriteSize = 0; + if (writev) { + // data is an array of strings or ArrayBufferViews. We're going to concat + // them all together into a single buffer so we can write all at once. This + // trades higher memory use by copying the input buffers for fewer round trips + // through the write loop in the stream. Since the write loops involve bouncing + // back and forth across the kj event loop boundary and requires reacquiring the + // isolate lock after each write, this should be more efficient in the long run. + const buffers = []; + for (const d of data) { + if (typeof d.chunk === 'string') { + const buf = Buffer.from(d.chunk, d.encoding); + buffers.push(buf); + lastWriteSize += buf.byteLength; + } else if (isArrayBufferView(d.chunk)) { + buffers.push( + new Uint8Array( + d.chunk.buffer, + d.chunk.byteOffset, + d.chunk.byteLength + ) + ); + lastWriteSize += d.chunk.byteLength; + } else { + throw new ERR_INVALID_ARG_TYPE( + 'chunk', + ['string', 'ArrayBufferView'], + d.chunk + ); + } + } + this._unrefTimer(); + + this._handle.writer.write(Buffer.concat(buffers)).then( + () => { + if (this._handle != null) { + this._handle.bytesWritten += this[kLastWriteQueueSize] ?? 0; + } else { + this[kBytesWritten] += this[kLastWriteQueueSize] ?? 0; + } + this[kLastWriteQueueSize] = 0; + this._unrefTimer(); + cb(); + }, + (err: unknown): void => { + this[kLastWriteQueueSize] = 0; + this._unrefTimer(); + cb(err as Error); + } + ); + } else { + let bufferData: Buffer; + if (typeof data === 'string') { + bufferData = Buffer.from(data, encoding); + } else { + bufferData = data as unknown as Buffer; + } + this._handle.writer.write(bufferData).then( + () => { + if (this._handle != null) { + this._handle.bytesWritten += this[kLastWriteQueueSize] ?? 0; + } else { + this[kBytesWritten] += this[kLastWriteQueueSize] ?? 0; + } + this[kLastWriteQueueSize] = 0; + this._unrefTimer(); + cb(); + }, + (err: unknown): void => { + this[kLastWriteQueueSize] = 0; + this._unrefTimer(); + cb(err as Error); + } + ); + lastWriteSize = (data as unknown as Buffer).byteLength; + } + this[kLastWriteQueueSize] = lastWriteSize; + } catch (err) { + this.destroy(err as Error); + } +}; + +Socket.prototype._writev = function ( + this: SocketClass, + chunks: { chunk: string | ArrayBufferView; encoding: string }[], + cb: () => void +): void { + this._writeGeneric(true, chunks, '', cb); +}; + +Socket.prototype._write = function ( + this: SocketClass, + data: { chunk: string | ArrayBufferView; encoding: string }[], + encoding: string, + cb: (err?: Error) => void +): void { + this._writeGeneric(false, data, encoding, cb); +}; + +Socket.prototype._final = function ( + this: SocketClass, + cb: (err?: Error) => void +): void { + if (this.connecting) { + this.once('connect', () => { + this._final(cb); + }); + return; + } + + // If there is no writer, then there's really nothing left to do here. + if (this._handle?.writer === undefined) { + cb(); + return; + } + + this._handle.writer.close().then( + (): void => { + cb(); + }, + (err: unknown): void => { + cb(err as Error); + } + ); +}; + +// @ts-expect-error TS2322 No easy way to enable this. +Socket.prototype.end = function ( + this: SocketClass, + data: string | Uint8Array, + encoding?: NodeJS.BufferEncoding, + cb?: () => void +): SocketClass { + Duplex.prototype.end.call(this, data, encoding, cb); + return this; +}; + +// ====================================================================================== +// Readable side + +Socket.prototype.pause = function (this: SocketClass): SocketClass { + if (this.destroyed) return this; + // If the read loop is already running, setting reading to false + // will interrupt it after the current read completes (if any) + if (this._handle) this._handle.reading = false; + return Duplex.prototype.pause.call(this) as unknown as SocketClass; +}; + +Socket.prototype.resume = function (this: SocketClass): SocketClass { + if (this.destroyed) return this; + maybeStartReading(this); + return Duplex.prototype.resume.call(this) as unknown as SocketClass; +}; + +Socket.prototype.read = function ( + this: SocketClass, + n: number +): ReturnType { + if (this.destroyed) return; + maybeStartReading(this); + + return Duplex.prototype.read.call(this, n); +}; + +Socket.prototype._read = function (this: SocketClass, n: number): void { + if (this.connecting || !this._handle) { + this.once('connect', () => { + this._read(n); + }); + } else if (!this._handle.reading) { + maybeStartReading(this); + } +}; + +// ====================================================================================== +// Destroy and reset + +Socket.prototype._reset = function (this: SocketClass): SocketClass { + return this.destroy(); +}; + +Socket.prototype.resetAndDestroy = function (this: SocketClass): SocketClass { + // In Node.js, the resetAndDestroy method is used to "[close] the TCP connection by + // sending an RST packet and destroy the stream. If this TCP socket is in connecting + // status, it will send an RST packet and destroy this TCP socket once it is connected. + // Otherwise, it will call socket.destroy with an ERR_SOCKET_CLOSED Error. If this is + // not a TCP socket (for example, a pipe), calling this method will immediately throw + // an ERR_INVALID_HANDLE_TYPE Error." In our implementation we really don't have a way + // of ensuring whether or not an RST packet is actually sent so this is largely an + // alias for the existing destroy. If the socket is still connecting, it will be + // destroyed immediately after the connection is established. + if (this.destroyed) return this; + if (this._handle) { + if (this.connecting) { + this.once('connect', () => { + this._reset(); + }); + } else { + this._reset(); + } + } else { + this.destroy(new ERR_SOCKET_CLOSED()); + } + return this; +}; + +Socket.prototype.destroySoon = function (this: SocketClass): void { + if (this.destroyed) return; + if (this.writable) { + this.end(); + } + + if (this.writableFinished) { + this.destroy(); + } else { + this.once('finish', this.destroy.bind(this)); + } +}; + +Socket.prototype._destroy = function ( + this: SocketClass, + exception: Error, + cb: (err?: Error) => void +): void { + if (this[kTimeout]) { + clearTimeout(this[kTimeout] as unknown as number); + this[kTimeout] = undefined; + } + + if (this._handle) { + this._handle.socket.close().then( + () => { + cleanupAfterDestroy(this, cb, exception); + }, + (err: unknown) => { + cleanupAfterDestroy(this, cb, (err || exception) as Error); + } + ); + } else { + cleanupAfterDestroy(this, cb, exception); + } +}; + +// ====================================================================================== +// Connection + +Socket.prototype.connect = function ( + this: SocketClass, + ...args: unknown[] +): SocketClass { + if (this.connecting) { + throw new ERR_SOCKET_CONNECTING(); + } + // TODO(later): In Node.js a Socket instance can be reset so that it can be reused. + // We haven't yet implemented that here. We can consider doing so but it's not an + // immediate priority. Implementing it correctly requires making sure the internal + // state of the socket is correctly reset. + if (this.destroyed) { + throw new ERR_SOCKET_CLOSED(); + } + let normalized; + // @ts-expect-error TS7015 Required not to overcomplicate types + if (Array.isArray(args[0]) && args[0][normalizedArgsSymbol]) { + normalized = args[0]; + } else { + normalized = normalizeArgs(args); + } + const options = normalized[0] as TcpSocketConnectOpts & IpcSocketConnectOpts; + const cb = normalized[1] as VoidFunction | null; + + if (cb !== null) { + this.once('connect', cb); + } + + if (this._parent && this._parent.connecting) { + return this; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (options.port === undefined && options.path == null) { + throw new ERR_MISSING_ARGS(['options', 'port', 'path']); + } + + if (this.write !== Socket.prototype.write) { + // eslint-disable-next-line @typescript-eslint/unbound-method + this.write = Socket.prototype.write; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.destroyed) { + this._handle = null; + this._peername = null; + this._sockname = null; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (options.path != null) { + throw new ERR_INVALID_ARG_VALUE('path', options.path, 'is not supported'); + } + + this[kBytesRead] = 0; + this[kBytesWritten] = 0; + + initializeConnection(this, options); + + return this; +}; + +// ====================================================================================== +// Socket methods that are not no-ops or nominal impls + +Socket.prototype.setNoDelay = function ( + this: SocketClass, + _enable?: boolean +): SocketClass { + // Ignore this for now. + // Cloudflare connect() does not support this. + return this; +}; + +Socket.prototype.setKeepAlive = function ( + this: SocketClass, + enable?: boolean, + _initialDelay?: number +): SocketClass { + if (!enable) return this; + throw new ERR_INVALID_ARG_VALUE('enable', enable, 'is not supported'); +}; + +// @ts-expect-error TS2322 Intentionally no-op +Socket.prototype.ref = function (this: SocketClass): void { + // Intentional no-op +}; + +// @ts-expect-error TS2322 Intentionally no-op +Socket.prototype.unref = function (this: SocketClass): void { + // Intentional no-op +}; + +Object.defineProperties(Socket.prototype, { + _connecting: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + get(this: SocketClass): boolean { + return this.connecting; + }, + }, + pending: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + get(this: SocketClass): boolean { + return !this._handle || this.connecting; + }, + configurable: true, + }, + readyState: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + get(this: SocketClass): string { + if (this.connecting) { + return 'opening'; + } else if (this.readable && this.writable) { + return 'open'; + } else if (this.readable && !this.writable) { + return 'readOnly'; + } else if (!this.readable && this.writable) { + return 'writeOnly'; + } + return 'closed'; + }, + }, + writableLength: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): number { + return this._writableState?.length ?? 0; + }, + }, + bufferSize: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + get(this: SocketClass): number | undefined { + if (this._handle) { + return this.writableLength; + } + return; + }, + }, + [kUpdateTimer]: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + get(this: SocketClass): VoidFunction { + // eslint-disable-next-line @typescript-eslint/unbound-method + return this._unrefTimer; + }, + }, + bytesRead: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): number { + return this._handle ? this._handle.bytesRead : this[kBytesRead]; + }, + }, + bytesWritten: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): number { + const flushed = + this._handle != null ? this._handle.bytesWritten : this[kBytesWritten]; + const pending = + this._writableState != null ? this._writableState.length : 0; + return flushed + pending; + }, + }, + remoteAddress: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): unknown { + return this._getpeername().address; + }, + }, + remoteFamily: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): unknown { + return this._getpeername().family; + }, + }, + remotePort: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): unknown { + return this._getpeername().port; + }, + }, + localAddress: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(): string { + return '0.0.0.0'; + }, + }, + localPort: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): number { + return 0; + }, + }, + localFamily: { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + configurable: false, + enumerable: true, + get(this: SocketClass): string { + return 'IPv4'; + }, + }, +}); + +// ====================================================================================== +// Helper/utility methods + +function cleanupAfterDestroy( + socket: SocketClass, + cb: (err?: Error) => void, + error?: Error +): void { + if (socket._handle != null) { + socket[kBytesRead] = socket.bytesRead; + socket[kBytesWritten] = socket.bytesWritten; + } + socket._handle = null; + socket[kLastWriteQueueSize] = 0; + socket[kBuffer] = null; + socket[kBufferCb] = null; + socket[kBufferGen] = null; + socket[kSocketInfo] = null; + cb(error); + queueMicrotask(() => socket.emit('close', error != null)); +} + +function initializeConnection( + socket: SocketClass, + options: TcpSocketConnectOpts +): void { + // options.localAddress, options.localPort, and options.family are ignored. + const { + host = 'localhost', + family, + hints, + autoSelectFamily, + lookup, + } = options; + let { port = 0 } = options; + + if (autoSelectFamily != null) { + // We don't support this option. If the value is falsy, we can safely ignore it. + // If the value is truthy, we'll throw an error. + throw new ERR_INVALID_ARG_VALUE( + 'options.autoSelectFamily', + autoSelectFamily, + 'is not supported' + ); + } + + if (typeof port !== 'number' && typeof port !== 'string') { + throw new ERR_INVALID_ARG_TYPE('options.port', ['number', 'string'], port); + } + + port = validatePort(+port); + + socket.connecting = true; + + const continueConnection = ( + host: unknown, + port: number, + family: number | string + ): void => { + socket._unrefTimer(); + socket[kSocketInfo] = { + remoteAddress: { + address: host, + port, + family: family === 4 ? 'IPv4' : family === 6 ? 'IPv6' : undefined, + }, + }; + + if (family === 6) { + // The host is an IPv6 address. We need to wrap it in square brackets. + host = `[${host}]`; + } + + // @ts-expect-error TS2540 Unnecessary error due to using @types/node + socket.autoSelectFamilyAttemptedAddresses = [`${host}:${port}`]; + + socket.emit('connectionAttempt', host, port, addressType); + + socket._host = `${host}`; + + try { + const handle = inner.connect(`${host}:${port}`, { + allowHalfOpen: socket.allowHalfOpen, + // A Node.js socket is always capable of being upgraded to the TLS socket. + secureTransport: 'starttls', + // We are not going to pass the high water mark here. The outer Node.js + // stream will implement the appropriate backpressure for us. + }); + + // Our version of the socket._handle is necessarily different than Node.js'. + // It serves the same purpose but any code that may exist that is depending + // on `_handle` being a particular type (which it shouldn't be) will fail. + socket._handle = { + socket: handle, + writer: handle.writable.getWriter(), + reader: handle.readable.getReader({ mode: 'byob' }), + bytesRead: 0, + bytesWritten: 0, + }; + + handle.opened.then(onConnectionOpened.bind(socket), (err: unknown) => { + socket.emit('connectionAttemptFailed', host, port, addressType, err); + socket.destroy(err as Error); + }); + + handle.closed.then( + onConnectionClosed.bind(socket), + // eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable + socket.destroy.bind(socket) + ); + } catch (err) { + socket.destroy(err as Error); + } + }; + + const addressType = isIP(host); + + if (addressType === 0) { + // The host is not an IP address. That's allowed in our implementation, but let's + // see if the user provided a lookup function. If not, we'll skip. + if (typeof lookup !== 'undefined') { + validateFunction(lookup, 'options.lookup'); + // Looks like we have a lookup function! Let's call it. The expectation is that + // the lookup function will produce a good IP address from the non-IP address + // that is given. How that is done is left entirely up to the application code. + // The connection attempt will continue once the lookup function invokes the + // given callback. + lookup( + host, + { family: family || addressType, hints }, + (err: Error | null, address: string, family: number | string): void => { + socket.emit('lookup', err, address, family, host); + if (err) { + socket.destroy(err); + return; + } + if (isIP(address) === 0) { + throw new ERR_INVALID_ARG_VALUE( + 'address', + address, + 'must be an IPv4 or IPv6 address' + ); + } + if ( + family !== 4 && + family !== 6 && + family !== 'IPv4' && + family !== 'IPv6' + ) { + throw new ERR_INVALID_ARG_VALUE('family', family, 'must be 4 or 6'); + } + continueConnection(address, port, family); + } + ); + return; + } + } + + continueConnection(host, port, addressType); +} + +function onConnectionOpened(this: SocketClass): void { + try { + // The info.remoteAddress property is going to give the + // address in the form of a string like `${host}:{port}`. We can choose + // to pull that out here but it's not critical at this point. + this.connecting = false; + this._unrefTimer(); + + this.emit('connect'); + this.emit('ready'); + + if (!this.isPaused()) { + maybeStartReading(this); + } + } catch (err) { + this.destroy(err as Error); + } +} + +function onConnectionClosed(this: SocketClass): void { + if (this[kTimeout] != null) clearTimeout(this[kTimeout] as unknown as number); + // TODO(later): What else should we do here? Anything? +} + +async function startRead(socket: SocketClass): Promise { + if (!socket._handle) return; + const reader = socket._handle.reader; + try { + while (socket._handle.reading === true) { + // The [kBufferGen] function should always be a function that returns + // a Uint8Array we can read into. + // @ts-expect-error TS2721 Required due to types + const { value, done } = await reader.read(socket[kBufferGen]()); + + // Make sure the socket was not destroyed while we were waiting. + // If it was, we're going to throw away the chunk of data we just + // read. + if (socket.destroyed) { + // Doh! Well, this is awkward. Let's just stop reading and return. + // There's really nothing else we should try to do here. + break; + } + + // Reset the timeout timer since we received data. + socket._unrefTimer(); + + if (done) { + // All done! If allowHalfOpen is true, then this will just end the + // readable side of the socket. If allowHalfOpen is false, then this + // should allow the current write queue to drain but not allow any + // further writes to be queued. + socket.push(null); + break; + } + + // If the byteLength is zero, skip the push. + if (value.byteLength === 0) { + continue; + } + socket._handle.bytesRead += value.byteLength; + + // The socket API is expected to produce Buffer instances, not Uint8Arrays + const buffer = Buffer.from( + value.buffer, + value.byteOffset, + value.byteLength + ); + + if (typeof socket[kBufferCb] === 'function') { + if (socket[kBufferCb](buffer.byteLength, buffer) === false) { + // If the callback returns explicitly false (not falsy) then + // we're being asked to stop reading for now. + break; + } + continue; + } + + // Because we're pushing the buffer onto the stream we can't use the shared + // buffer here or the next read will overwrite it! We need to copy. For the + // more efficient version, use onread. + if (!socket.push(Buffer.from(buffer))) { + // If push returns false, we've hit the high water mark and should stop + // reading until the stream requests to start reading again. + break; + } + } + } finally { + // Disable eslint to match Node.js behavior + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (socket._handle != null) { + socket._handle.reading = false; + } + } +} + +function maybeStartReading(socket: SocketClass): void { + if ( + socket[kBuffer] && + !socket.connecting && + socket._handle && + !socket._handle.reading + ) { + socket._handle.reading = true; + startRead(socket).catch((err: unknown) => socket.destroy(err as Error)); + } +} + +function writeAfterFIN( + this: SocketClass, + chunk: Uint8Array | string, + encoding?: NodeJS.BufferEncoding, + cb?: (err?: Error) => void +): boolean { + if (!this.writableEnded) { + // @ts-expect-error TS2554 Required due to @types/node + return Duplex.prototype.write.call(this, chunk, encoding, cb); + } + + if (typeof encoding === 'function') { + cb = encoding; + } + + const er = new EPIPE(); + + queueMicrotask(() => cb?.(er)); + this.destroy(er); + + return false; +} + +function onReadableStreamEnd(this: SocketClass): void { + if (!this.allowHalfOpen) { + // @ts-expect-error TS2554 Required due to @types/node + this.write = writeAfterFIN; + } +} + +function getTimerDuration(msecs: unknown, name: string): number { + validateNumber(msecs, name); + if (msecs < 0 || !Number.isFinite(msecs)) { + throw new ERR_OUT_OF_RANGE(name, 'a non-negative finite number', msecs); + } + + // Ensure that msecs fits into signed int32 + if (msecs > TIMEOUT_MAX) { + return TIMEOUT_MAX; + } + + return msecs; +} + +function toNumber(x: unknown): number | false { + return (x = Number(x)) >= 0 ? (x as number) : false; +} + +function isPipeName(s: unknown): boolean { + return typeof s === 'string' && toNumber(s) === false; +} + +function normalizeArgs(args: unknown[]): unknown[] { + let arr: unknown[]; + + if (args.length === 0) { + arr = [{}, null]; + // @ts-expect-error TS2554 Required due to @types/node + arr[normalizedArgsSymbol] = true; + return arr; + } + + const arg0 = args[0]; + let options: { + path?: string; + port?: number; + host?: string; + } = {}; + if (typeof arg0 === 'object' && arg0 !== null) { + // (options[...][, cb]) + options = arg0; + } else if (isPipeName(arg0)) { + // (path[...][, cb]) + options.path = arg0 as string; + } else { + // ([port][, host][...][, cb]) + options.port = arg0 as number; + if (args.length > 1 && typeof args[1] === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + options.host = args[1] as string; + } + } + + const cb = args[args.length - 1]; + if (typeof cb !== 'function') arr = [options, null]; + else arr = [options, cb]; + + // @ts-expect-error TS2554 Required due to @types/node + arr[normalizedArgsSymbol] = true; + return arr; +} + +function addClientAbortSignalOption( + self: SocketClass, + options: { signal?: AbortSignal } +): void { + validateAbortSignal(options.signal, 'options.signal'); + const { signal } = options; + let disposable: Disposable | undefined; + + function onAbort(): void { + disposable?.[Symbol.dispose](); + self._aborted = true; + // TODO(now): What else should be do here? Anything? + } + + if (signal.aborted) { + queueMicrotask(onAbort); + } else { + queueMicrotask(() => { + disposable = addAbortListener(signal, onAbort); + }); + } +} + +function addAbortListener( + signal: AbortSignal | undefined, + listener: VoidFunction +): Disposable { + if (signal === undefined) { + throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal); + } + validateAbortSignal(signal, 'signal'); + validateFunction(listener, 'listener'); + + let removeEventListener: undefined | (() => void); + if (signal.aborted) { + queueMicrotask(() => { + listener(); + }); + } else { + signal.addEventListener('abort', listener, { once: true }); + removeEventListener = (): void => { + signal.removeEventListener('abort', listener); + }; + } + return { + // @ts-expect-error TS2353 Required for __proto__ + __proto__: null, + [Symbol.dispose](): void { + removeEventListener?.(); + }, + }; +} + +// ====================================================================================== +// The rest of the exports + +export function connect(...args: unknown[]): SocketClass { + const normalized = normalizeArgs(args); + const options = normalized[0] as SocketOptions; + // @ts-expect-error TS7009 Required for type usage + const socket: SocketClass = new Socket(options); + if (options.timeout) { + socket.setTimeout(options.timeout); + } + if (socket.destroyed) { + return socket; + } + return socket.connect(normalized as unknown as SocketConnectOpts); +} + +export const createConnection = connect; + +export function getDefaultAutoSelectFamily(): boolean { + // This is the only value we support. + return false; +} + +export function setDefaultAutoSelectFamily(val: unknown): void { + if (!val) return; + throw new ERR_INVALID_ARG_VALUE('val', val); +} + +// We don't actually make use of this. It's here only for compatibility. +// The value is not used anywhere. +let autoSelectFamilyAttemptTimeout: number = 10; + +export function getDefaultAutoSelectFamilyAttemptTimeout(): number { + return autoSelectFamilyAttemptTimeout; +} + +export function setDefaultAutoSelectFamilyAttemptTimeout(val: unknown): void { + validateInt32(val, 'val', 1); + if (val < 10) val = 10; + autoSelectFamilyAttemptTimeout = val as number; +} + +export function isIP(input: unknown): number { + if (isIPv4(input)) return 4; + if (isIPv6(input)) return 6; + return 0; +} + +export function isIPv4(input: unknown): boolean { + input = typeof input !== 'string' ? `${input}` : input; + return IPv4Reg.test(input as string); +} + +export function isIPv6(input: unknown): boolean { + input = typeof input !== 'string' ? `${input}` : input; + return IPv6Reg.test(input as string); +} + +export default { + Stream: Socket, + Socket, + connect, + createConnection, + getDefaultAutoSelectFamily, + setDefaultAutoSelectFamily, + getDefaultAutoSelectFamilyAttemptTimeout, + setDefaultAutoSelectFamilyAttemptTimeout, + isIP, + isIPv4, + isIPv6, + _normalizeArgs: normalizeArgs, +}; diff --git a/src/node/tsconfig.json b/src/node/tsconfig.json index 1a926356e18..0784c82b020 100644 --- a/src/node/tsconfig.json +++ b/src/node/tsconfig.json @@ -29,6 +29,7 @@ "node:path/*": ["./*"], "node:stream/*": ["./*"], "node-internal:*": ["./internal/*"], + "cloudflare-internal:sockets": ["./internal/sockets.d.ts"], "cloudflare-internal:workers": ["./internal/workers.d.ts"], "workerd:compatibility-flags": ["./internal/compatibility-flags.d.ts"] } diff --git a/src/workerd/api/node/BUILD.bazel b/src/workerd/api/node/BUILD.bazel index 93e7ce90211..1e348768f40 100644 --- a/src/workerd/api/node/BUILD.bazel +++ b/src/workerd/api/node/BUILD.bazel @@ -1,3 +1,4 @@ +load("@aspect_rules_js//js:defs.bzl", "js_binary") load("@bazel_skylib//lib:selects.bzl", "selects") load("//:build/kj_test.bzl", "kj_test") load("//:build/wd_cc_library.bzl", "wd_cc_library") @@ -253,3 +254,21 @@ wd_test( args = ["--experimental"], data = ["tests/dns-nodejs-test.js"], ) + +js_binary( + name = "net-nodejs-tcp-server", + entry_point = "tests/net-nodejs-tcp-server.js", +) + +wd_test( + size = "large", + src = "tests/net-nodejs-test.wd-test", + args = ["--experimental"], + data = ["tests/net-nodejs-test.js"], + sidecar = "net-nodejs-tcp-server", + target_compatible_with = select({ + # TODO(soon): Investigate why this test timeout on Windows. + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) diff --git a/src/workerd/api/node/tests/net-nodejs-tcp-server.js b/src/workerd/api/node/tests/net-nodejs-tcp-server.js new file mode 100644 index 00000000000..c4d352a946d --- /dev/null +++ b/src/workerd/api/node/tests/net-nodejs-tcp-server.js @@ -0,0 +1,42 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// This file is used as a sidecar for the net-nodejs-test tests. +// It creates 2 TCP servers to act as a source of truth for the node:net tests. +// We execute this command using Node.js, which makes net.createServer available. +const net = require('node:net'); + +const server = net.createServer((s) => { + s.on('error', () => { + // Do nothing + }); + s.end(); +}); +server.listen(9999, () => console.info('Listening on port 9999')); + +const echoServer = net.createServer((s) => { + s.setTimeout(100); + s.on('error', () => { + // Do nothing + }); + s.pipe(s); +}); +echoServer.listen(9998, () => console.info('Listening on port 9998')); + +const timeoutServer = net.createServer((s) => { + s.setTimeout(100); + s.resume(); + s.once('timeout', () => { + // Try to reset the timeout. + s.write('WHAT.'); + }); + + s.on('end', () => { + s.end(); + }); + s.on('error', () => { + // Do nothing + }); +}); +timeoutServer.listen(9997, () => console.info('Listening on port 9997')); diff --git a/src/workerd/api/node/tests/net-nodejs-test.js b/src/workerd/api/node/tests/net-nodejs-test.js new file mode 100644 index 00000000000..8718cb7446e --- /dev/null +++ b/src/workerd/api/node/tests/net-nodejs-test.js @@ -0,0 +1,1125 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT ORs +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { fail, ok, strictEqual, throws } from 'node:assert'; +import { mock } from 'node:test'; +import { once } from 'node:events'; +import * as net from 'node:net'; + +const enc = new TextEncoder(); + +// test/parallel/test-net-access-byteswritten.js +export const testNetAccessBytesWritten = { + test() { + // Check that the bytesWritten getter doesn't crash if object isn't + // constructed. Also check bytesRead... + + // TODO(now): Should be undefined + strictEqual(net.Socket.prototype.bytesWritten, NaN); + strictEqual(net.Socket.prototype.bytesRead, undefined); + }, +}; + +// test/parallel/test-net-after-close.js +export const testNetAfterClose = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.resume(); + c.on('close', () => resolve()); + await promise; + + // Calling functions / accessing properties of a closed socket should not throw + c.setNoDelay(); + c.setKeepAlive(); + c.bufferSize; + c.pause(); + c.resume(); + c.address(); + c.remoteAddress; + c.remotePort; + }, +}; + +// test/parallel/test-net-allow-half-open.js +export const testNetAllowHalfOpen = { + async test() { + // Verify that the socket closes propertly when the other end closes + // and allowHalfOpen is false. + + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + strictEqual(c.allowHalfOpen, false); + c.resume(); + + const endFn = mock.fn(() => { + queueMicrotask(() => { + ok(!c.destroyed); + }); + }); + const finishFn = mock.fn(() => { + ok(!c.destroyed); + }); + const closeFn = mock.fn(() => { + resolve(); + }); + c.on('end', endFn); + + // Even tho we're not writing anything, since the socket receives a + // EOS and allowHalfOpen is false, the socket should close both the + // readable and writable sides, meaning we should definitely get a + // finish event. + c.on('finish', finishFn); + c.on('close', closeFn); + await promise; + strictEqual(endFn.mock.callCount(), 1); + strictEqual(finishFn.mock.callCount(), 1); + strictEqual(closeFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-better-error-messages-port-hostname.js +export const testNetBetterErrorMessagesPortHostname = { + async test() { + // This is intentionally not a completely faithful reproduction of the + // original test, as we don't have the ability to mock DNS lookups. + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(0, 'invalid address'); + c.on('connect', () => { + throw new Error('should not connect'); + }); + const errorFn = mock.fn((error) => { + // TODO(review): Currently our errors do not match the errors that + // Node.js produces. Do we need them to? Specifically, in a case like + // this, Node.js' error would have a meaningful `code` property along + // with `hostname` and `syscall` properties. We, instead, are passing + // along the underlying error returned from the internal Socket API. + try { + strictEqual(error.message, 'Specified address could not be parsed.'); + } catch (err) { + console.log(err.message); + } + resolve(); + }); + c.on('error', errorFn); + await promise; + strictEqual(errorFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-binary.js +export const testNetBinary = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + + // Connect to the echo server + const c = net.connect(9998, 'localhost'); + c.setEncoding('latin1'); + let result = ''; + c.on('data', (chunk) => { + result += chunk; + }); + + let binaryString = ''; + for (let i = 255; i >= 0; i--) { + c.write(String.fromCharCode(i), 'latin1'); + binaryString += String.fromCharCode(i); + } + c.end(); + c.on('close', () => { + resolve(); + }); + await promise; + strictEqual(result, binaryString); + }, +}; + +// test/parallel/test-net-buffersize.js +export const testNetBuffersize = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + const finishFn = mock.fn(() => { + strictEqual(c.bufferSize, 0); + resolve(); + }); + c.on('finish', finishFn); + + strictEqual(c.bufferSize, 0); + c.write('a'); + c.end(); + strictEqual(c.bufferSize, 1); + await promise; + strictEqual(finishFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-bytes-read.js +export const testNetBytesRead = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + // Connect to the echo server + const c = net.connect(9998, 'localhost'); + c.resume(); + c.write('hello'); + c.end(); + const endFn = mock.fn(() => { + strictEqual(c.bytesRead, 5); + resolve(); + }); + c.on('end', endFn); + + await promise; + + strictEqual(endFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-bytes-stats.js +// export const testNetBytesStats = { +// async test() { +// // This is intentionally not a completely faithful reproduction of the +// // original test which checks the bytesRead on the server side. +// +// // Connect to the echo server +// const { promise, resolve } = Promise.withResolvers(); +// const c = net.connect(9998, 'localhost'); +// let bytesDelivered = 0; +// c.on('data', (chunk) => (bytesDelivered += chunk.byteLength)); +// c.write('hello'); +// c.end(); +// const endFn = mock.fn(() => { +// strictEqual(c.bytesWritten, 5); +// strictEqual(bytesDelivered, 5); +// strictEqual(c.bytesRead, 5); +// resolve(); +// }); +// c.on('end', endFn); +// +// await promise; +// strictEqual(endFn.mock.callCount(), 1); +// }, +// }; + +// test/parallel/test-net-bytes-written-large.js +const N = 10000000; +export const testNetBytesWrittenLargeVariant1 = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9998, 'localhost'); + c.resume(); + + const writeFn = mock.fn(() => { + strictEqual(c.bytesWritten, N); + resolve(); + }); + + c.end(Buffer.alloc(N), writeFn); + + await promise; + }, +}; + +// test/parallel/test-net-can-reset-timeout.js +export const testNetCanResetTimeout = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9997, 'localhost'); + const dataFn = mock.fn(() => { + c.end(); + }); + c.on('data', dataFn); + c.on('end', resolve); + + await promise; + + strictEqual(dataFn.mock.callCount(), 1); + }, +}; + +async function assertAbort(socket, testName) { + try { + await once(socket, 'close'); + fail(`close ${testName} should have thrown`); + } catch (err) { + strictEqual(err.name, 'AbortError'); + } +} + +// test/parallel/test-net-connect-abort-controller.js +export const testNetConnectAbortControllerPostAbort = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + const socket = net.connect({ port: 9999, signal }); + ac.abort(); + await assertAbort(socket, 'postAbort'); + }, +}; + +export const testNetConnectAbortControllerPreAbort = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + ac.abort(); + const socket = net.connect({ port: 9999, signal }); + await assertAbort(socket, 'preAbort'); + }, +}; + +export const testNetConnectAbortControllerTickAbort = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + queueMicrotask(() => ac.abort()); + const socket = net.connect({ port: 9999, signal }); + await assertAbort(socket, 'tickAbort'); + }, +}; + +export const testNetConnectAbortControllerConstructor = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + ac.abort(); + const socket = new net.Socket({ signal }); + await assertAbort(socket, 'testConstructor'); + }, +}; + +export const testNetConnectAbortControllerConstructorPost = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + const socket = new net.Socket({ signal }); + ac.abort(); + await assertAbort(socket, 'testConstructorPost'); + }, +}; + +export const testNetConnectAbortControllerConstructorPostTick = { + async test() { + const ac = new AbortController(); + const { signal } = ac; + queueMicrotask(() => ac.abort()); + const socket = new net.Socket({ signal }); + await assertAbort(socket, 'testConstructorPostTick'); + }, +}; + +// test/parallel/test-net-connect-after-destroy.js +export const testNetConnectAfterDestroy = { + async test() { + // Connect to something that we need to lookup, then delay + // the lookup so that the connect attempt happens after the + // destroy + const lookup = mock.fn((host, options, callback) => { + setTimeout(() => callback(null, 'localhost'), 100); + }); + const c = net.connect({ + port: 80, + host: 'example.org', + lookup, + }); + c.on('connect', () => { + throw new Error('should not connect'); + }); + c.destroy(); + strictEqual(c.destroyed, true); + strictEqual(lookup.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-connect-buffer.js +export const testNetConnectBuffer = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect({ + port: 9998, + host: 'localhost', + highWaterMark: 0, + }); + + strictEqual(c.pending, true); + strictEqual(c.connecting, true); + strictEqual(c.readyState, 'opening'); + strictEqual(c.bytesWritten, 0); + + // Write a string that contains a multi-byte character sequence to test that + // `bytesWritten` is incremented with the # of bytes, not # of characters. + const a = "L'État, c'est "; + const b = 'moi'; + + let result = ''; + c.setEncoding('utf8'); + c.on('data', (chunk) => { + result += chunk; + }); + const endFn = mock.fn(() => { + strictEqual(result, a + b); + }); + c.on('end', endFn); + + const writeFn = mock.fn(() => { + strictEqual(c.pending, false); + strictEqual(c.connecting, false); + strictEqual(c.readyState, 'readOnly'); + strictEqual(c.bytesWritten, Buffer.from(a + b).length); + }); + c.write(a, writeFn); + + const closeFn = mock.fn(() => { + resolve(); + }); + c.on('close', closeFn); + + c.end(b); + + await promise; + strictEqual(closeFn.mock.callCount(), 1); + strictEqual(writeFn.mock.callCount(), 1); + strictEqual(endFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-connect-destroy.js +export const testNetConnectDestroy = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.on('close', () => resolve()); + c.destroy(); + await promise; + }, +}; + +// test/parallel/test-net-connect-immediate-destroy.js +export const testNetConnectImmediateDestroy = { + async test() { + const connectFn = mock.fn(); + const socket = net.connect(9999, 'localhost', connectFn); + socket.destroy(); + await Promise.resolve(); + strictEqual(connectFn.mock.callCount(), 0); + }, +}; + +// test/parallel/test-net-connect-immediate-finish.js +export const testNetConnectImmediateFinish = { + async text() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.end(); + c.on('finish', () => resolve()); + await promise; + }, +}; + +// test/parallel/test-net-connect-keepalive.js +// test/parallel/test-net-keepalive.js +// We don't actually support keep alive so this test does +// something different than the original Node.js test +export const testNetConnectKeepAlive = { + async test() { + throws(() => new net.Socket({ keepAlive: true })); + const c = new net.Socket(); + c.setKeepAlive(false); + throws(() => c.setKeepAlive(true)); + }, +}; + +// test/parallel/test-net-connect-memleak.js +export const testNetConnectMemleak = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const connectFn = mock.fn(() => resolve()); + const c = net.connect(9999, 'localhost', connectFn); + c.emit('connect'); + await promise; + strictEqual(connectFn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-connect-no-arg.js +export const testNetConnectNoArg = { + test() { + throws(() => net.connect(), { + code: 'ERR_MISSING_ARGS', + message: 'The "options" or "port" or "path" argument must be specified', + }); + throws(() => new net.Socket().connect(), { + code: 'ERR_MISSING_ARGS', + message: 'The "options" or "port" or "path" argument must be specified', + }); + throws(() => net.connect({}), { + code: 'ERR_MISSING_ARGS', + message: 'The "options" or "port" or "path" argument must be specified', + }); + throws(() => new net.Socket().connect({}), { + code: 'ERR_MISSING_ARGS', + message: 'The "options" or "port" or "path" argument must be specified', + }); + }, +}; + +// test/parallel/test-net-connect-options-allowhalfopen.js +// Simplified version of the equivalent Node.js test +export const testNetConnectOptionsAllowHalfOpen = { + async test() { + const { promise, resolve, reject } = Promise.withResolvers(); + const c = net.connect({ port: 9999, allowHalfOpen: true }); + c.resume(); + const writeFn = mock.fn(() => { + c.write('hello', (err) => { + if (err) reject(err); + resolve(); + }); + }); + const endFn = mock.fn(() => { + strictEqual(c.readable, false); + strictEqual(c.writable, true); + queueMicrotask(writeFn); + }); + c.on('end', endFn); + await promise; + }, +}; + +// test/parallel/test-net-connect-options-fd.js +// We do not support the fd option so this test does something different +// than the original Node.js test +export const testNetConnectOptionsFd = { + async test() { + throws(() => new net.Socket({ fd: 42 })); + }, +}; + +// test/parallel/test-net-connect-options-invalid.js +export const testNetConnectOptionsInvalid = { + test() { + ['objectMode', 'readableObjectMode', 'writableObjectMode'].forEach( + (invalidKey) => { + const option = { + port: 8080, + [invalidKey]: true, + }; + const message = `The property 'options.${invalidKey}' is not supported. Received true`; + + throws(() => net.connect(option), { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: new RegExp(message), + }); + } + ); + }, +}; + +// test/parallel/test-net-connect-options-ipv6.js +export const testNetConnectOptionsIpv6 = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect({ host: '::1', port: 9999 }); + c.on('connect', () => resolve()); + await promise; + }, +}; + +// test/parallel/test-net-connect-options-path.js +// We do not support the path option so this test does something different +// than the original Node.js test +export const testNetConnectOptionsPath = { + async test() { + throws(() => net.connect({ path: '/tmp/sock' })); + }, +}; + +// test/parallel/test-net-connect-options-port.js +export const testNetConnectOptionsPort = { + async test() { + [true, false].forEach((port) => { + throws(() => net.connect(port), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + }); + [-1, 65537].forEach((port) => { + throws(() => net.connect(port), { + code: 'ERR_SOCKET_BAD_PORT', + }); + }); + }, +}; + +// test/parallel/test-net-connect-paused-connection.js +// The original test is a bit different given that it uses unref to avoid +// the paused connection from keeping the process alive. We don't have unref +// so let's just make sure things clean up okay when the IoContext is destroyed. +export const testNetConnectPausedConnection = { + test() { + net.connect(9999, 'localhost').pause(); + }, +}; + +// test/parallel/test-net-dns-custom-lookup.js +// test/parallel/test-net-dns-lookup.js +export const testNetDnsCustomLookup = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const lookup = mock.fn((host, options, callback) => { + strictEqual(host, 'localhost'); + strictEqual(options.family, 0); + queueMicrotask(() => callback(null, '127.0.0.1', 4)); + }); + const c = net.connect({ + port: 9999, + host: 'localhost', + lookup, + }); + c.on('lookup', (err, ip, type) => { + strictEqual(err, null); + strictEqual(ip, '127.0.0.1'); + strictEqual(type, 4); + resolve(); + }); + await promise; + strictEqual(lookup.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-dns-lookup-skip.js +export const testNetDnsLookupSkip = { + async test() { + const lookup = mock.fn(); + ['127.0.0.1', '::1'].forEach((host) => { + net.connect({ host, port: 9999, lookup }).destroy(); + }); + strictEqual(lookup.mock.callCount(), 0); + }, +}; + +// test/parallel/test-net-during-close.js +export const testNetDuringClose = { + test() { + const c = net.connect(9999, 'localhost'); + c.destroy(); + c.remoteAddress; + c.remoteFamily; + c.remotePort; + }, +}; + +// test/parallel/test-net-end-destroyed.js +export const testNetEndDestroyed = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.resume(); + + const endFn = mock.fn(() => { + strictEqual(c.destroyed, false); + resolve(); + }); + c.on('end', endFn); + await promise; + }, +}; + +// test/parallel/test-net-end-without-connect.js +export const testNetEndWithoutConnect = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = new net.Socket(); + const endFn = mock.fn(() => { + strictEqual(c.writable, false); + resolve(); + }); + c.end(endFn); + await promise; + }, +}; + +// test/parallel/test-net-isip.js +export const testNetIsIp = { + test() { + strictEqual(net.isIP('127.0.0.1'), 4); + strictEqual(net.isIP('x127.0.0.1'), 0); + strictEqual(net.isIP('example.com'), 0); + strictEqual(net.isIP('0000:0000:0000:0000:0000:0000:0000:0000'), 6); + strictEqual(net.isIP('0000:0000:0000:0000:0000:0000:0000:0000::0000'), 0); + strictEqual(net.isIP('1050:0:0:0:5:600:300c:326b'), 6); + strictEqual(net.isIP('2001:252:0:1::2008:6'), 6); + strictEqual(net.isIP('2001:dead:beef:1::2008:6'), 6); + strictEqual(net.isIP('2001::'), 6); + strictEqual(net.isIP('2001:dead::'), 6); + strictEqual(net.isIP('2001:dead:beef::'), 6); + strictEqual(net.isIP('2001:dead:beef:1::'), 6); + strictEqual(net.isIP('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'), 6); + strictEqual(net.isIP(':2001:252:0:1::2008:6:'), 0); + strictEqual(net.isIP(':2001:252:0:1::2008:6'), 0); + strictEqual(net.isIP('2001:252:0:1::2008:6:'), 0); + strictEqual(net.isIP('2001:252::1::2008:6'), 0); + strictEqual(net.isIP('::2001:252:1:2008:6'), 6); + strictEqual(net.isIP('::2001:252:1:1.1.1.1'), 6); + strictEqual(net.isIP('::2001:252:1:255.255.255.255'), 6); + strictEqual(net.isIP('::2001:252:1:255.255.255.255.76'), 0); + strictEqual(net.isIP('fe80::2008%eth0'), 6); + strictEqual(net.isIP('fe80::2008%eth0.0'), 6); + strictEqual(net.isIP('fe80::2008%eth0@1'), 0); + strictEqual(net.isIP('::anything'), 0); + strictEqual(net.isIP('::1'), 6); + strictEqual(net.isIP('::'), 6); + strictEqual(net.isIP('0000:0000:0000:0000:0000:0000:12345:0000'), 0); + strictEqual(net.isIP('0'), 0); + strictEqual(net.isIP(), 0); + strictEqual(net.isIP(''), 0); + strictEqual(net.isIP(null), 0); + strictEqual(net.isIP(123), 0); + strictEqual(net.isIP(true), 0); + strictEqual(net.isIP({}), 0); + strictEqual( + net.isIP({ toString: () => '::2001:252:1:255.255.255.255' }), + 6 + ); + strictEqual(net.isIP({ toString: () => '127.0.0.1' }), 4); + strictEqual(net.isIP({ toString: () => 'bla' }), 0); + + strictEqual(net.isIPv4('127.0.0.1'), true); + strictEqual(net.isIPv4('example.com'), false); + strictEqual(net.isIPv4('2001:252:0:1::2008:6'), false); + strictEqual(net.isIPv4(), false); + strictEqual(net.isIPv4(''), false); + strictEqual(net.isIPv4(null), false); + strictEqual(net.isIPv4(123), false); + strictEqual(net.isIPv4(true), false); + strictEqual(net.isIPv4({}), false); + strictEqual( + net.isIPv4({ toString: () => '::2001:252:1:255.255.255.255' }), + false + ); + strictEqual(net.isIPv4({ toString: () => '127.0.0.1' }), true); + strictEqual(net.isIPv4({ toString: () => 'bla' }), false); + + strictEqual(net.isIPv6('127.0.0.1'), false); + strictEqual(net.isIPv6('example.com'), false); + strictEqual(net.isIPv6('2001:252:0:1::2008:6'), true); + strictEqual(net.isIPv6(), false); + strictEqual(net.isIPv6(''), false); + strictEqual(net.isIPv6(null), false); + strictEqual(net.isIPv6(123), false); + strictEqual(net.isIPv6(true), false); + strictEqual(net.isIPv6({}), false); + strictEqual( + net.isIPv6({ toString: () => '::2001:252:1:255.255.255.255' }), + true + ); + strictEqual(net.isIPv6({ toString: () => '127.0.0.1' }), false); + strictEqual(net.isIPv6({ toString: () => 'bla' }), false); + }, +}; + +// test/parallel/test-net-isipv4.js +export const testNetIsIpv4 = { + test() { + const v4 = [ + '0.0.0.0', + '8.8.8.8', + '127.0.0.1', + '100.100.100.100', + '192.168.0.1', + '18.101.25.153', + '123.23.34.2', + '172.26.168.134', + '212.58.241.131', + '128.0.0.0', + '23.71.254.72', + '223.255.255.255', + '192.0.2.235', + '99.198.122.146', + '46.51.197.88', + '173.194.34.134', + ]; + + const v4not = [ + '.100.100.100.100', + '100..100.100.100.', + '100.100.100.100.', + '999.999.999.999', + '256.256.256.256', + '256.100.100.100.100', + '123.123.123', + 'http://123.123.123', + '1000.2.3.4', + '999.2.3.4', + '0000000192.168.0.200', + '192.168.0.2000000000', + ]; + + for (const ip of v4) { + strictEqual(net.isIPv4(ip), true); + } + + for (const ip of v4not) { + strictEqual(net.isIPv4(ip), false); + } + }, +}; + +// test/parallel/test-net-isipv6.js +export const testNetIsIpv6 = { + test() { + const v6 = [ + '::', + '1::', + '::1', + '1::8', + '1::7:8', + '1:2:3:4:5:6:7:8', + '1:2:3:4:5:6::8', + '1:2:3:4:5:6:7::', + '1:2:3:4:5::7:8', + '1:2:3:4:5::8', + '1:2:3::8', + '1::4:5:6:7:8', + '1::6:7:8', + '1::3:4:5:6:7:8', + '1:2:3:4::6:7:8', + '1:2::4:5:6:7:8', + '::2:3:4:5:6:7:8', + '1:2::8', + '2001:0000:1234:0000:0000:C1C0:ABCD:0876', + '3ffe:0b00:0000:0000:0001:0000:0000:000a', + 'FF02:0000:0000:0000:0000:0000:0000:0001', + '0000:0000:0000:0000:0000:0000:0000:0001', + '0000:0000:0000:0000:0000:0000:0000:0000', + '::ffff:192.168.1.26', + '2::10', + 'ff02::1', + 'fe80::', + '2002::', + '2001:db8::', + '2001:0db8:1234::', + '::ffff:0:0', + '::ffff:192.168.1.1', + '1:2:3:4::8', + '1::2:3:4:5:6:7', + '1::2:3:4:5:6', + '1::2:3:4:5', + '1::2:3:4', + '1::2:3', + '::2:3:4:5:6:7', + '::2:3:4:5:6', + '::2:3:4:5', + '::2:3:4', + '::2:3', + '::8', + '1:2:3:4:5:6::', + '1:2:3:4:5::', + '1:2:3:4::', + '1:2:3::', + '1:2::', + '1:2:3:4::7:8', + '1:2:3::7:8', + '1:2::7:8', + '1:2:3:4:5:6:1.2.3.4', + '1:2:3:4:5::1.2.3.4', + '1:2:3:4::1.2.3.4', + '1:2:3::1.2.3.4', + '1:2::1.2.3.4', + '1::1.2.3.4', + '1:2:3:4::5:1.2.3.4', + '1:2:3::5:1.2.3.4', + '1:2::5:1.2.3.4', + '1::5:1.2.3.4', + '1::5:11.22.33.44', + 'fe80::217:f2ff:254.7.237.98', + 'fe80::217:f2ff:fe07:ed62', + '2001:DB8:0:0:8:800:200C:417A', + 'FF01:0:0:0:0:0:0:101', + '0:0:0:0:0:0:0:1', + '0:0:0:0:0:0:0:0', + '2001:DB8::8:800:200C:417A', + 'FF01::101', + '0:0:0:0:0:0:13.1.68.3', + '0:0:0:0:0:FFFF:129.144.52.38', + '::13.1.68.3', + '::FFFF:129.144.52.38', + 'fe80:0000:0000:0000:0204:61ff:fe9d:f156', + 'fe80:0:0:0:204:61ff:fe9d:f156', + 'fe80::204:61ff:fe9d:f156', + 'fe80:0:0:0:204:61ff:254.157.241.86', + 'fe80::204:61ff:254.157.241.86', + 'fe80::1', + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + '2001:db8:85a3:0:0:8a2e:370:7334', + '2001:db8:85a3::8a2e:370:7334', + '2001:0db8:0000:0000:0000:0000:1428:57ab', + '2001:0db8:0000:0000:0000::1428:57ab', + '2001:0db8:0:0:0:0:1428:57ab', + '2001:0db8:0:0::1428:57ab', + '2001:0db8::1428:57ab', + '2001:db8::1428:57ab', + '::ffff:12.34.56.78', + '::ffff:0c22:384e', + '2001:0db8:1234:0000:0000:0000:0000:0000', + '2001:0db8:1234:ffff:ffff:ffff:ffff:ffff', + '2001:db8:a::123', + '::ffff:192.0.2.128', + '::ffff:c000:280', + 'a:b:c:d:e:f:f1:f2', + 'a:b:c::d:e:f:f1', + 'a:b:c::d:e:f', + 'a:b:c::d:e', + 'a:b:c::d', + '::a', + '::a:b:c', + '::a:b:c:d:e:f:f1', + 'a::', + 'a:b:c::', + 'a:b:c:d:e:f:f1::', + 'a:bb:ccc:dddd:000e:00f:0f::', + '0:a:0:a:0:0:0:a', + '0:a:0:0:a:0:0:a', + '2001:db8:1:1:1:1:0:0', + '2001:db8:1:1:1:0:0:0', + '2001:db8:1:1:0:0:0:0', + '2001:db8:1:0:0:0:0:0', + '2001:db8:0:0:0:0:0:0', + '2001:0:0:0:0:0:0:0', + 'A:BB:CCC:DDDD:000E:00F:0F::', + '0:0:0:0:0:0:0:a', + '0:0:0:0:a:0:0:0', + '0:0:0:a:0:0:0:0', + 'a:0:0:a:0:0:a:a', + 'a:0:0:a:0:0:0:a', + 'a:0:0:0:a:0:0:a', + 'a:0:0:0:a:0:0:0', + 'a:0:0:0:0:0:0:0', + 'fe80::7:8%eth0', + 'fe80::7:8%1', + ]; + + const v6not = [ + '', + '1:', + ':1', + '11:36:12', + '02001:0000:1234:0000:0000:C1C0:ABCD:0876', + '2001:0000:1234:0000:00001:C1C0:ABCD:0876', + '2001:0000:1234: 0000:0000:C1C0:ABCD:0876', + '2001:1:1:1:1:1:255Z255X255Y255', + '3ffe:0b00:0000:0001:0000:0000:000a', + 'FF02:0000:0000:0000:0000:0000:0000:0000:0001', + '3ffe:b00::1::a', + '::1111:2222:3333:4444:5555:6666::', + '1:2:3::4:5::7:8', + '12345::6:7:8', + '1::5:400.2.3.4', + '1::5:260.2.3.4', + '1::5:256.2.3.4', + '1::5:1.256.3.4', + '1::5:1.2.256.4', + '1::5:1.2.3.256', + '1::5:300.2.3.4', + '1::5:1.300.3.4', + '1::5:1.2.300.4', + '1::5:1.2.3.300', + '1::5:900.2.3.4', + '1::5:1.900.3.4', + '1::5:1.2.900.4', + '1::5:1.2.3.900', + '1::5:300.300.300.300', + '1::5:3000.30.30.30', + '1::400.2.3.4', + '1::260.2.3.4', + '1::256.2.3.4', + '1::1.256.3.4', + '1::1.2.256.4', + '1::1.2.3.256', + '1::300.2.3.4', + '1::1.300.3.4', + '1::1.2.300.4', + '1::1.2.3.300', + '1::900.2.3.4', + '1::1.900.3.4', + '1::1.2.900.4', + '1::1.2.3.900', + '1::300.300.300.300', + '1::3000.30.30.30', + '::400.2.3.4', + '::260.2.3.4', + '::256.2.3.4', + '::1.256.3.4', + '::1.2.256.4', + '::1.2.3.256', + '::300.2.3.4', + '::1.300.3.4', + '::1.2.300.4', + '::1.2.3.300', + '::900.2.3.4', + '::1.900.3.4', + '::1.2.900.4', + '::1.2.3.900', + '::300.300.300.300', + '::3000.30.30.30', + '2001:DB8:0:0:8:800:200C:417A:221', + 'FF01::101::2', + '1111:2222:3333:4444::5555:', + '1111:2222:3333::5555:', + '1111:2222::5555:', + '1111::5555:', + '::5555:', + ':::', + '1111:', + ':', + ':1111:2222:3333:4444::5555', + ':1111:2222:3333::5555', + ':1111:2222::5555', + ':1111::5555', + ':::5555', + '1.2.3.4:1111:2222:3333:4444::5555', + '1.2.3.4:1111:2222:3333::5555', + '1.2.3.4:1111:2222::5555', + '1.2.3.4:1111::5555', + '1.2.3.4::5555', + '1.2.3.4::', + 'fe80:0000:0000:0000:0204:61ff:254.157.241.086', + '123', + 'ldkfj', + '2001::FFD3::57ab', + '2001:db8:85a3::8a2e:37023:7334', + '2001:db8:85a3::8a2e:370k:7334', + '1:2:3:4:5:6:7:8:9', + '1::2::3', + '1:::3:4:5', + '1:2:3::4:5:6:7:8:9', + '::ffff:2.3.4', + '::ffff:257.1.2.3', + '::ffff:12345678901234567890.1.26', + '2001:0000:1234:0000:0000:C1C0:ABCD:0876 0', + '02001:0000:1234:0000:0000:C1C0:ABCD:0876', + ]; + + for (const ip of v6) { + strictEqual(net.isIPv6(ip), true); + } + + for (const ip of v6not) { + strictEqual(net.isIPv6(ip), false); + } + }, +}; + +// test/parallel/test-net-large-string.js +export const testNetLargeString = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9998, 'localhost'); + let response = ''; + const size = 40 * 1024; + const data = 'あ'.repeat(size); + c.setEncoding('utf8'); + c.on('data', (data) => (response += data)); + c.end(data); + c.on('close', resolve); + await promise; + strictEqual(response.length, size); + strictEqual(response, data); + }, +}; + +// test/parallel/test-net-local-address-port.js +// The localAddress information is a non-op in our implementation +export const testNetLocalAddressPort = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.on('connect', () => { + strictEqual(c.localAddress, '0.0.0.0'); + strictEqual(c.localPort, 0); + resolve(); + }); + await promise; + }, +}; + +// test/parallel/test-net-onread-static-buffer.js +export const testNetOnReadStaticBuffer = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const buffer = Buffer.alloc(1024); + const fn = mock.fn((nread, buf) => { + strictEqual(nread, 5); + strictEqual(buf.buffer.byteLength, 1024); + resolve(); + }); + const c = net.connect({ + port: 9998, + host: 'localhost', + onread: { + buffer, + callback: fn, + }, + }); + c.on('data', () => { + throw new Error('Should not have failed'); + }); + c.write('hello'); + await promise; + strictEqual(fn.mock.callCount(), 1); + }, +}; + +// test/parallel/test-net-remote-address-port.js +// test/parallel/test-net-remote-address.js +export const testNetRemoteAddress = { + async test() { + const { promise, resolve } = Promise.withResolvers(); + const c = net.connect(9999, 'localhost'); + c.on('connect', () => { + strictEqual(c.remoteAddress, 'localhost'); + strictEqual(c.remotePort, 9999); + resolve(); + }); + await promise; + }, +}; + +export default { + connect({ inbound }) { + inbound.cancel(); + return new ReadableStream({ + start(c) { + c.close(); + }, + }); + }, +}; + +export const echoServer = { + connect({ inbound }) { + return inbound.pipeThrough(new IdentityTransformStream()); + }, +}; diff --git a/src/workerd/api/node/tests/net-nodejs-test.wd-test b/src/workerd/api/node/tests/net-nodejs-test.wd-test new file mode 100644 index 00000000000..775b7c4c875 --- /dev/null +++ b/src/workerd/api/node/tests/net-nodejs-test.wd-test @@ -0,0 +1,16 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "net-nodejs-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "net-nodejs-test.js") + ], + compatibilityDate = "2025-01-09", + compatibilityFlags = ["nodejs_compat"], + ) + ), + ( name = "internet", network = ( allow = ["private"] ) ), + ], +); diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index 66df8049037..49a4367f425 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -6,6 +6,7 @@ js_library( visibility = ["//visibility:public"], deps = [ "//:node_modules/@eslint/js", + "//:node_modules/@types/node", "//:node_modules/typescript-eslint", ], )