Skip to content

Commit

Permalink
Explicit Resource Management (#1830)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
ardatan and github-actions[bot] committed Nov 25, 2024
1 parent 103f67e commit e88ab4a
Show file tree
Hide file tree
Showing 28 changed files with 457 additions and 195 deletions.
5 changes: 5 additions & 0 deletions .changeset/@whatwg-node_node-fetch-1830-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@whatwg-node/node-fetch": patch
---
dependencies updates:
- Added dependency [`@whatwg-node/disposablestack@^0.0.5` ↗︎](https://www.npmjs.com/package/@whatwg-node/disposablestack/v/0.0.5) (to `dependencies`)
5 changes: 5 additions & 0 deletions .changeset/@whatwg-node_server-1830-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@whatwg-node/server": patch
---
dependencies updates:
- Added dependency [`@whatwg-node/disposablestack@^0.0.5` ↗︎](https://www.npmjs.com/package/@whatwg-node/disposablestack/v/0.0.5) (to `dependencies`)
14 changes: 14 additions & 0 deletions .changeset/mean-chairs-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@whatwg-node/server': patch
---

New Explicit Resource Management feature for the server adapters;
[Learn more](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html)
- `Symbol.dispose` and `Symbol.asyncDispose` hooks
When the server adapter plugin has these hooks, it is added to the disposable stack of the server adapter. When the server adapter is disposed, those hooks are triggered
- `disposableStack` in the server adapter
The shared disposable stack that will be triggered when `Symbol.asyncDispose` is called.
- Automatic disposal on Node and Node-compatible environments
Even if the server adapter is not disposed explicitly, the disposal logic will be triggered on the process termination (SIGINT, SIGTERM etc)
- ctx.waitUntil relation
If it is an environment does not natively provide `waitUntil`, the unresolved passed promises will be resolved by the disposable stack.
3 changes: 2 additions & 1 deletion packages/disposablestack/tests/disposablestack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,15 @@ const stacks: Record<
},
};

patchSymbols();

for (const stackName in stacks) {
describe(stackName, () => {
const StackCtor = stacks[stackName].ctor;
const disposeFnSymbol = stacks[stackName].symbol;
const disposeMethod = stacks[stackName].disposeMethod;
const testCases = createTestCases(disposeFnSymbol);
describe('using syntax', () => {
patchSymbols();
for (const testCaseName in testCases) {
const testCase = testCases[testCaseName];
it(testCaseName, async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/node-fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"typings": "dist/typings/index.d.ts",
"dependencies": {
"@kamilkisiela/fast-url-parser": "^1.1.4",
"@whatwg-node/disposablestack": "^0.0.5",
"busboy": "^1.6.0",
"fast-querystring": "^1.1.1",
"tslib": "^2.6.3"
Expand Down
7 changes: 3 additions & 4 deletions packages/node-fetch/src/IteratorObject.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { inspect } from 'node:util';
import { DisposableSymbols } from '@whatwg-node/disposablestack';
import { isIterable } from './utils.js';

export class PonyfillIteratorObject<T> implements IteratorObject<T, undefined, unknown> {
Expand Down Expand Up @@ -128,10 +129,8 @@ export class PonyfillIteratorObject<T> implements IteratorObject<T, undefined, u
return Array.from(this.iterableIterator);
}

[Symbol.dispose](): void {
if (typeof (this.iterableIterator as any).return === 'function') {
(this.iterableIterator as any).return();
}
[DisposableSymbols.dispose](): void {
this.iterableIterator.return?.();
}

next(...[value]: [] | [unknown]): IteratorResult<T, undefined> {
Expand Down
53 changes: 28 additions & 25 deletions packages/node-fetch/src/ReadableStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,27 +72,28 @@ export class PonyfillReadableStream<T> implements ReadableStream<T> {
} else {
let started = false;
let ongoing = false;
const readImpl = async (desiredSize: number) => {
if (!started) {
const controller = createController(desiredSize, this.readable);
started = true;
await underlyingSource?.start?.(controller);
controller._flush();
if (controller._closed) {
return;
}
}
const controller = createController(desiredSize, this.readable);
await underlyingSource?.pull?.(controller);
controller._flush();
ongoing = false;
};
this.readable = new Readable({
read(desiredSize) {
if (ongoing) {
return;
}
ongoing = true;
return Promise.resolve().then(async () => {
if (!started) {
const controller = createController(desiredSize, this);
started = true;
await underlyingSource?.start?.(controller);
controller._flush();
if (controller._closed) {
return;
}
}
const controller = createController(desiredSize, this);
await underlyingSource?.pull?.(controller);
controller._flush();
ongoing = false;
});
return readImpl(desiredSize);
},
destroy(err, callback) {
if (underlyingSource?.cancel) {
Expand Down Expand Up @@ -174,6 +175,17 @@ export class PonyfillReadableStream<T> implements ReadableStream<T> {
throw new Error('Not implemented');
}

private async pipeToWriter(writer: WritableStreamDefaultWriter<T>): Promise<void> {
try {
for await (const chunk of this) {
await writer.write(chunk);
}
await writer.close();
} catch (err) {
await writer.abort(err);
}
}

pipeTo(destination: WritableStream<T>): Promise<void> {
if (isPonyfillWritableStream(destination)) {
return new Promise((resolve, reject) => {
Expand All @@ -183,16 +195,7 @@ export class PonyfillReadableStream<T> implements ReadableStream<T> {
});
} else {
const writer = destination.getWriter();
return Promise.resolve().then(async () => {
try {
for await (const chunk of this) {
await writer.write(chunk);
}
await writer.close();
} catch (err) {
await writer.abort(err);
}
});
return this.pipeToWriter(writer);
}
}

Expand Down
51 changes: 42 additions & 9 deletions packages/node-fetch/src/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Agent as HTTPAgent } from 'http';
import { Agent as HTTPSAgent } from 'https';
import { BodyPonyfillInit, PonyfillBody, PonyfillBodyOptions } from './Body.js';
import { isHeadersLike, PonyfillHeaders, PonyfillHeadersInit } from './Headers.js';
import { PonyfillURL } from './URL.js';

function isRequest(input: any): input is PonyfillRequest {
return input[Symbol.toStringTag] === 'Request';
Expand All @@ -27,16 +28,23 @@ function isURL(obj: any): obj is URL {

export class PonyfillRequest<TJSON = any> extends PonyfillBody<TJSON> implements Request {
constructor(input: RequestInfo | URL, options?: RequestPonyfillInit) {
let url: string | undefined;
let _url: string | undefined;
let _parsedUrl: URL | undefined;
let bodyInit: BodyPonyfillInit | null = null;
let requestInit: RequestPonyfillInit | undefined;

if (typeof input === 'string') {
url = input;
_url = input;
} else if (isURL(input)) {
url = input.toString();
_parsedUrl = input;
} else if (isRequest(input)) {
url = input.url;
if (input._parsedUrl) {
_parsedUrl = input._parsedUrl;
} else if (input._url) {
_url = input._url;
} else {
_url = input.url;
}
bodyInit = input.body;
requestInit = input;
}
Expand All @@ -48,6 +56,9 @@ export class PonyfillRequest<TJSON = any> extends PonyfillBody<TJSON> implements

super(bodyInit, options);

this._url = _url;
this._parsedUrl = _parsedUrl;

this.cache = requestInit?.cache || 'default';
this.credentials = requestInit?.credentials || 'same-origin';
this.headers =
Expand All @@ -66,8 +77,6 @@ export class PonyfillRequest<TJSON = any> extends PonyfillBody<TJSON> implements
this.headersSerializer = requestInit?.headersSerializer;
this.duplex = requestInit?.duplex || 'half';

this.url = url || '';

this.destination = 'document';
this.priority = 'auto';

Expand All @@ -76,11 +85,12 @@ export class PonyfillRequest<TJSON = any> extends PonyfillBody<TJSON> implements
}

if (requestInit?.agent != null) {
const protocol = _parsedUrl?.protocol || _url || this.url;
if (requestInit.agent === false) {
this.agent = false;
} else if (this.url.startsWith('http:/') && requestInit.agent instanceof HTTPAgent) {
} else if (protocol.startsWith('http:') && requestInit.agent instanceof HTTPAgent) {
this.agent = requestInit.agent;
} else if (this.url.startsWith('https:/') && requestInit.agent instanceof HTTPSAgent) {
} else if (protocol.startsWith('https:') && requestInit.agent instanceof HTTPSAgent) {
this.agent = requestInit.agent;
}
}
Expand All @@ -99,7 +109,30 @@ export class PonyfillRequest<TJSON = any> extends PonyfillBody<TJSON> implements
redirect: RequestRedirect;
referrer: string;
referrerPolicy: ReferrerPolicy;
url: string;
_url: string | undefined;
get url(): string {
if (this._url == null) {
if (this._parsedUrl) {
this._url = this._parsedUrl.toString();
} else {
throw new TypeError('Invalid URL');
}
}
return this._url;
}

_parsedUrl: URL | undefined;
get parsedUrl(): URL {
if (this._parsedUrl == null) {
if (this._url != null) {
this._parsedUrl = new PonyfillURL(this._url, 'http://localhost');
} else {
throw new TypeError('Invalid URL');
}
}
return this._parsedUrl;
}

duplex: 'half' | 'full';

agent: HTTPAgent | HTTPSAgent | false | undefined;
Expand Down
11 changes: 7 additions & 4 deletions packages/node-fetch/src/URL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ export class PonyfillURL extends FastUrl implements URL {
this.parse(url, false);
if (base) {
const baseParsed = typeof base === 'string' ? new PonyfillURL(base) : base;
this.protocol = this.protocol || baseParsed.protocol;
this.host = this.host || baseParsed.host;
this.pathname = this.pathname || baseParsed.pathname;
this.protocol ||= baseParsed.protocol;
this.host ||= baseParsed.host;
this.pathname ||= baseParsed.pathname;
this.port ||= baseParsed.port;
}
}

get origin(): string {
return `${this.protocol}//${this.host}`;
return `${this.protocol}//${this.host}${this.port ? `:${this.port}` : ''}`;
}

private _searchParams?: PonyfillURLSearchParams;
Expand Down Expand Up @@ -80,4 +81,6 @@ export class PonyfillURL extends FastUrl implements URL {
static getBlobFromURL(url: string): Blob | PonyfillBlob | undefined {
return (this.blobRegistry.get(url) || resolveObjectURL(url)) as Blob | PonyfillBlob | undefined;
}

[Symbol.toStringTag] = 'URL';
}
2 changes: 1 addition & 1 deletion packages/node-fetch/src/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ declare module '@kamilkisiela/fast-url-parser' {
path: string;
pathname: string;
protocol: string;
search: string;
readonly search: string;
slashes: boolean;
port: string;
query: string | any;
Expand Down
39 changes: 31 additions & 8 deletions packages/node-fetch/src/fetchNodeHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export function fetchNodeHttp<TResponseJSON = any, TRequestJSON = any>(
): Promise<PonyfillResponse<TResponseJSON>> {
return new Promise((resolve, reject) => {
try {
const requestFn = getRequestFnForProtocol(fetchRequest.url);
const requestFn = getRequestFnForProtocol(
fetchRequest.parsedUrl?.protocol || fetchRequest.url,
);

const nodeReadable = (
fetchRequest.body != null
Expand All @@ -37,12 +39,30 @@ export function fetchNodeHttp<TResponseJSON = any, TRequestJSON = any>(
nodeHeaders['accept-encoding'] = 'gzip, deflate, br';
}

const nodeRequest = requestFn(fetchRequest.url, {
method: fetchRequest.method,
headers: nodeHeaders,
signal: fetchRequest['_signal'] ?? undefined,
agent: fetchRequest.agent,
});
let nodeRequest: ReturnType<typeof requestFn>;

if (fetchRequest.parsedUrl) {
nodeRequest = requestFn({
auth: fetchRequest.parsedUrl.username
? `${fetchRequest.parsedUrl.username}:${fetchRequest.parsedUrl.password}`
: undefined,
hostname: fetchRequest.parsedUrl.hostname,
method: fetchRequest.method,
path: fetchRequest.parsedUrl.pathname + (fetchRequest.parsedUrl.search || ''),
port: fetchRequest.parsedUrl.port,
protocol: fetchRequest.parsedUrl.protocol,
headers: nodeHeaders,
signal: fetchRequest['_signal'] ?? undefined,
agent: fetchRequest.agent,
});
} else {
nodeRequest = requestFn(fetchRequest.url, {
method: fetchRequest.method,
headers: nodeHeaders,
signal: fetchRequest['_signal'] ?? undefined,
agent: fetchRequest.agent,
});
}

nodeRequest.once('response', nodeResponse => {
let outputStream: PassThrough;
Expand Down Expand Up @@ -74,7 +94,10 @@ export function fetchNodeHttp<TResponseJSON = any, TRequestJSON = any>(
return;
}
if (fetchRequest.redirect === 'follow') {
const redirectedUrl = new PonyfillURL(nodeResponse.headers.location, fetchRequest.url);
const redirectedUrl = new PonyfillURL(
nodeResponse.headers.location,
fetchRequest.parsedUrl || fetchRequest.url,
);
const redirectResponse$ = fetchNodeHttp(
new PonyfillRequest(redirectedUrl, fetchRequest),
);
Expand Down
6 changes: 1 addition & 5 deletions packages/node-fetch/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ export function getHeadersObj(headers: Headers): Record<string, string> {
if (headers == null || !isHeadersInstance(headers)) {
return headers as any;
}
const obj: Record<string, string> = {};
headers.forEach((value, key) => {
obj[key] = value;
});
return obj;
return Object.fromEntries(headers.entries());
}

export function defaultHeadersSerializer(
Expand Down
Loading

0 comments on commit e88ab4a

Please sign in to comment.