diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000000..25e8c103c5b --- /dev/null +++ b/.clang-format @@ -0,0 +1,7 @@ +WhitespaceSensitiveMacros: + # clang format doesn't understand TypeScript, so make sure it doesn't mangle + # overrides and additional definitions + - JSG_TS_OVERRIDE + - JSG_TS_DEFINE + - JSG_STRUCT_TS_OVERRIDE + - JSG_STRUCT_TS_DEFINE diff --git a/src/workerd/api/actor-state.h b/src/workerd/api/actor-state.h index 4413db78591..a3503e9a1c3 100644 --- a/src/workerd/api/actor-state.h +++ b/src/workerd/api/actor-state.h @@ -42,6 +42,7 @@ class DurableObjectStorageOperations { } JSG_STRUCT(allowConcurrency, noCache); + JSG_STRUCT_TS_OVERRIDE(DurableObjectGetOptions); // Rename from DurableObjectStorageOperationsGetOptions }; jsg::Promise get( @@ -52,6 +53,7 @@ class DurableObjectStorageOperations { jsg::Optional allowConcurrency; JSG_STRUCT(allowConcurrency); + JSG_STRUCT_TS_OVERRIDE(DurableObjectGetAlarmOptions); // Rename from DurableObjectStorageOperationsGetAlarmOptions }; jsg::Promise> getAlarm(jsg::Optional options, v8::Isolate* isolate); @@ -74,6 +76,7 @@ class DurableObjectStorageOperations { } JSG_STRUCT(start, startAfter, end, prefix, reverse, limit, allowConcurrency, noCache); + JSG_STRUCT_TS_OVERRIDE(DurableObjectListOptions); // Rename from DurableObjectStorageOperationsListOptions }; jsg::Promise list(jsg::Optional options, v8::Isolate* isolate); @@ -91,6 +94,7 @@ class DurableObjectStorageOperations { } JSG_STRUCT(allowConcurrency, allowUnconfirmed, noCache); + JSG_STRUCT_TS_OVERRIDE(DurableObjectPutOptions); // Rename from DurableObjectStorageOperationsPutOptions }; jsg::Promise put(jsg::Lock& js, @@ -114,6 +118,7 @@ class DurableObjectStorageOperations { } JSG_STRUCT(allowConcurrency, allowUnconfirmed); + JSG_STRUCT_TS_OVERRIDE(DurableObjectSetAlarmOptions); // Rename from DurableObjectStorageOperationsSetAlarmOptions }; jsg::Promise setAlarm(kj::Date scheduledTime, jsg::Optional options, @@ -179,6 +184,8 @@ class DurableObjectStorage: public jsg::Object, public DurableObjectStorageOpera jsg::Optional lowPriority; JSG_STRUCT(asOfTime, lowPriority); + JSG_STRUCT_TS_OVERRIDE(type TransactionOptions = never); + // Omit from definitions }; jsg::Promise transaction(jsg::Lock& js, @@ -200,6 +207,21 @@ class DurableObjectStorage: public jsg::Object, public DurableObjectStorageOpera JSG_METHOD(setAlarm); JSG_METHOD(deleteAlarm); JSG_METHOD(sync); + + JSG_TS_OVERRIDE({ + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + + list(options?: DurableObjectListOptions): Promise>; + + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise; + }); } protected: @@ -238,6 +260,21 @@ class DurableObjectTransaction final: public jsg::Object, public DurableObjectSt JSG_METHOD(getAlarm); JSG_METHOD(setAlarm); JSG_METHOD(deleteAlarm); + + JSG_TS_OVERRIDE({ + get(key: string, options?: DurableObjectGetOptions): Promise; + get(keys: string[], options?: DurableObjectGetOptions): Promise>; + + list(options?: DurableObjectListOptions): Promise>; + + put(key: string, value: T, options?: DurableObjectPutOptions): Promise; + put(entries: Record, options?: DurableObjectPutOptions): Promise; + + delete(key: string, options?: DurableObjectPutOptions): Promise; + delete(keys: string[], options?: DurableObjectPutOptions): Promise; + + deleteAll: never; + }); } protected: @@ -280,6 +317,8 @@ class ActorState: public jsg::Object { JSG_READONLY_INSTANCE_PROPERTY(id, getId); JSG_READONLY_INSTANCE_PROPERTY(transient, getTransient); JSG_READONLY_INSTANCE_PROPERTY(persistent, getPersistent); + + JSG_TS_OVERRIDE(type ActorState = never); } private: @@ -311,6 +350,14 @@ class DurableObjectState: public jsg::Object { JSG_READONLY_INSTANCE_PROPERTY(id, getId); JSG_READONLY_INSTANCE_PROPERTY(storage, getStorage); JSG_METHOD(blockConcurrencyWhile); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + readonly id: DurableObjectId; + readonly storage: DurableObjectStorage; + blockConcurrencyWhile(callback: () => Promise): Promise; + }); + // Make `storage` non-optional } private: diff --git a/src/workerd/api/actor.h b/src/workerd/api/actor.h index b376bae56f8..7442eea66e5 100644 --- a/src/workerd/api/actor.h +++ b/src/workerd/api/actor.h @@ -85,6 +85,14 @@ class DurableObject: public Fetcher { JSG_READONLY_INSTANCE_PROPERTY(id, getId); JSG_READONLY_INSTANCE_PROPERTY(name, getName); + + JSG_TS_DEFINE(interface DurableObject { + fetch(request: Request): Response | Promise; + alarm?(): void | Promise; + }); + JSG_TS_OVERRIDE(DurableObjectStub); + // Rename this resource type to DurableObjectStub, and make DurableObject + // the interface implemented by users' Durable Object classes. } private: @@ -132,6 +140,7 @@ class DurableObjectNamespace: public jsg::Object { JSG_METHOD(idFromName); JSG_METHOD(idFromString); JSG_METHOD(get); + JSG_TS_ROOT(); } private: diff --git a/src/workerd/api/analytics-engine.h b/src/workerd/api/analytics-engine.h index 13b09324f72..ca283c313ab 100644 --- a/src/workerd/api/analytics-engine.h +++ b/src/workerd/api/analytics-engine.h @@ -58,6 +58,7 @@ class AnalyticsEngine: public jsg::Object { JSG_RESOURCE_TYPE(AnalyticsEngine) { JSG_METHOD(writeDataPoint); + JSG_TS_ROOT(); } private: diff --git a/src/workerd/api/basics.h b/src/workerd/api/basics.h index a1e29f755ff..3b733e39b00 100644 --- a/src/workerd/api/basics.h +++ b/src/workerd/api/basics.h @@ -182,6 +182,9 @@ class ExtendableEvent: public Event { #if !WORKERD_API_BASICS_TEST JSG_LAZY_READONLY_INSTANCE_PROPERTY(actorState, getActorState); #endif + + JSG_TS_OVERRIDE({ actorState: never }); + // Omit `actorState` from definitions } }; @@ -307,6 +310,19 @@ class EventTarget: public jsg::Object { JSG_METHOD(addEventListener); JSG_METHOD(removeEventListener); JSG_METHOD(dispatchEvent); + + JSG_TS_DEFINE( + type EventListener = (event: EventType) => void; + interface EventListenerObject { + handleEvent(event: EventType): void; + } + type EventListenerOrEventListenerObject = EventListener | EventListenerObject; + ); + JSG_TS_OVERRIDE( = Record> { + addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; + removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; + dispatchEvent(event: EventMap[keyof EventMap]): boolean; + }); } JSG_REFLECTION(onEvents); diff --git a/src/workerd/api/cache.h b/src/workerd/api/cache.h index e4e7cc40442..e57f73d5588 100644 --- a/src/workerd/api/cache.h +++ b/src/workerd/api/cache.h @@ -68,6 +68,13 @@ class Cache: public jsg::Object { JSG_METHOD(put); JSG_METHOD(matchAll); JSG_METHOD(keys); + + JSG_TS_OVERRIDE({ + delete(request: RequestInfo, options?: CacheQueryOptions): Promise; + match(request: RequestInfo, options?: CacheQueryOptions): Promise; + put(request: RequestInfo, response: Response): Promise; + }); + // Use RequestInfo type alias to allow `URL`s as cache keys } private: diff --git a/src/workerd/api/crypto.h b/src/workerd/api/crypto.h index d20a2b3791c..632eef1aeb6 100644 --- a/src/workerd/api/crypto.h +++ b/src/workerd/api/crypto.h @@ -392,6 +392,7 @@ class SubtleCrypto: public jsg::Object { jsg::Optional t; JSG_STRUCT(r, d, t); + JSG_STRUCT_TS_OVERRIDE(RsaOtherPrimesInfo); // Rename from SubtleCryptoJsonWebKeyRsaOtherPrimesInfo }; // The following fields are defined in Section 3.1 of JSON Web Key (RFC 7517). @@ -424,6 +425,7 @@ class SubtleCrypto: public jsg::Object { jsg::Optional k; JSG_STRUCT(kty, use, key_ops, alg, ext, crv, x, y, d, n, e, p, q, dp, dq, qi, oth, k); + JSG_STRUCT_TS_OVERRIDE(JsonWebKey); // Rename from SubtleCryptoJsonWebKey }; using ImportKeyData = kj::OneOf, JsonWebKey>; @@ -593,6 +595,8 @@ class DigestStream: public WritableStream { } else { JSG_READONLY_INSTANCE_PROPERTY(digest, getDigest); } + + JSG_TS_OVERRIDE(extends WritableStream); } private: @@ -629,6 +633,20 @@ class Crypto: public jsg::Object { JSG_METHOD(randomUUID); JSG_NESTED_TYPE(DigestStream); + + JSG_TS_OVERRIDE({ + getRandomValues< + T extends + | Int8Array + | Uint8Array + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | BigInt64Array + | BigUint64Array + >(buffer: T): T; + }); } private: diff --git a/src/workerd/api/form-data.h b/src/workerd/api/form-data.h index 200065f45cd..9089d9f33ec 100644 --- a/src/workerd/api/form-data.h +++ b/src/workerd/api/form-data.h @@ -96,7 +96,7 @@ class FormData: public jsg::Object { jsg::Optional thisArg, const jsg::TypeHandler& handler); - JSG_RESOURCE_TYPE(FormData) { + JSG_RESOURCE_TYPE(FormData, CompatibilityFlags::Reader flags) { JSG_METHOD(append); JSG_METHOD_NAMED(delete, delete_); JSG_METHOD(get); @@ -109,6 +109,37 @@ class FormData: public jsg::Object { JSG_METHOD(forEach); JSG_ITERABLE(entries); + + if (flags.getFormDataParserSupportsFiles()) { + JSG_TS_OVERRIDE({ + append(name: string, value: string): void; + append(name: string, value: Blob, filename?: string): void; + + set(name: string, value: string): void; + set(name: string, value: Blob, filename?: string): void; + + entries(): IterableIterator<[key: string, value: File | string]>; + [Symbol.iterator](): IterableIterator<[key: string, value: File | string]>; + + forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; + }); + } else { + JSG_TS_OVERRIDE({ + get(name: string): string | null; + getAll(name: string): string[]; + + append(name: string, value: string): void; + append(name: string, value: Blob, filename?: string): void; + + set(name: string, value: string): void; + set(name: string, value: Blob, filename?: string): void; + + entries(): IterableIterator<[key: string, value: string]>; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; + + forEach(callback: (this: This, value: string, key: string, parent: FormData) => void, thisArg?: This): void; + }); + } } private: diff --git a/src/workerd/api/global-scope.h b/src/workerd/api/global-scope.h index b0c25a6de64..c8881e4bdd5 100644 --- a/src/workerd/api/global-scope.h +++ b/src/workerd/api/global-scope.h @@ -86,6 +86,14 @@ class WorkerGlobalScope: public EventTarget { JSG_NESTED_TYPE(EventTarget); JSG_METHOD(importScripts); + + JSG_TS_DEFINE(type WorkerGlobalScopeEventMap = { + fetch: FetchEvent; + scheduled: ScheduledEvent; + unhandledrejection: PromiseRejectionEvent; + rejectionhandled: PromiseRejectionEvent; + }); + JSG_TS_OVERRIDE(extends EventTarget); } static jsg::Ref constructor() = delete; @@ -133,6 +141,23 @@ struct ExportedHandler { JSG_STRUCT(fetch, trace, scheduled, alarm, self); + JSG_STRUCT_TS_ROOT(); + // ExportedHandler isn't included in the global scope, but we still want to + // include it in type definitions. + + JSG_STRUCT_TS_DEFINE( + type ExportedHandlerFetchHandler = (request: Request, env: Env, ctx: ExecutionContext) => Response | Promise; + type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise; + type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise; + ); + JSG_STRUCT_TS_OVERRIDE( { + fetch?: ExportedHandlerFetchHandler; + trace?: ExportedHandlerTraceHandler; + scheduled?: ExportedHandlerScheduledHandler; + alarm: never; + }); + // Make `env` parameter generic + jsg::Value env = nullptr; jsg::Optional> ctx = nullptr; // Values to pass for `env` and `ctx` when calling handlers. Note these have to be the last members @@ -214,6 +239,7 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope { struct StructuredCloneOptions { jsg::Optional> transfer; JSG_STRUCT(transfer); + JSG_STRUCT_TS_OVERRIDE(StructuredSerializeOptions); }; v8::Local structuredClone( @@ -385,6 +411,125 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope { JSG_NESTED_TYPE(FixedLengthStream); JSG_NESTED_TYPE(IdentityTransformStream); JSG_NESTED_TYPE(HTMLRewriter); + + JSG_TS_ROOT(); + JSG_TS_DEFINE( + interface Console { + "assert"(condition?: boolean, ...data: any[]): void; + clear(): void; + count(label?: string): void; + countReset(label?: string): void; + debug(...data: any[]): void; + dir(item?: any, options?: any): void; + dirxml(...data: any[]): void; + error(...data: any[]): void; + group(...data: any[]): void; + groupCollapsed(...data: any[]): void; + groupEnd(): void; + info(...data: any[]): void; + log(...data: any[]): void; + table(tabularData?: any, properties?: string[]): void; + time(label?: string): void; + timeEnd(label?: string): void; + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + trace(...data: any[]): void; + warn(...data: any[]): void; + } + const console: Console; + + type BufferSource = ArrayBufferView | ArrayBuffer; + namespace WebAssembly { + class CompileError extends Error { + constructor(message?: string); + } + class RuntimeError extends Error { + constructor(message?: string); + } + + type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128"; + interface GlobalDescriptor { + value: ValueType; + mutable?: boolean; + } + class Global { + constructor(descriptor: GlobalDescriptor, value?: any); + value: any; + valueOf(): any; + } + + type ImportValue = ExportValue | number; + type ModuleImports = Record; + type Imports = Record; + type ExportValue = Function | Global | Memory | Table; + type Exports = Record; + class Instance { + constructor(module: Module, imports?: Imports); + readonly exports: Exports; + } + + interface MemoryDescriptor { + initial: number; + maximum?: number; + shared?: boolean; + } + class Memory { + constructor(descriptor: MemoryDescriptor); + readonly buffer: ArrayBuffer; + grow(delta: number): number; + } + + type ImportExportKind = "function" | "global" | "memory" | "table"; + interface ModuleExportDescriptor { + kind: ImportExportKind; + name: string; + } + interface ModuleImportDescriptor { + kind: ImportExportKind; + module: string; + name: string; + } + abstract class Module { + static customSections(module: Module, sectionName: string): ArrayBuffer[]; + static exports(module: Module): ModuleExportDescriptor[]; + static imports(module: Module): ModuleImportDescriptor[]; + } + + type TableKind = "anyfunc" | "externref"; + interface TableDescriptor { + element: TableKind; + initial: number; + maximum?: number; + } + class Table { + constructor(descriptor: TableDescriptor, value?: any); + readonly length: number; + get(index: number): any; + grow(delta: number, value?: any): number; + set(index: number, value?: any): void; + } + + function instantiate(module: Module, imports?: Imports): Promise; + function validate(bytes: BufferSource): boolean; + } + ); + // workerd disables dynamic WebAssembly compilation, so `compile()`, `compileStreaming()`, the + // `instantiate()` override taking a `BufferSource` and `instantiateStreaming()` are omitted. + // `Module` is also declared `abstract` to disable its `BufferSource` constructor. + + JSG_TS_OVERRIDE({ + btoa(data: string): string; + + setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; + setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + + setInterval(callback: (...args: any[]) => void, msDelay?: number): number; + setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; + + structuredClone(value: T, options?: StructuredSerializeOptions): T; + + fetch(input: RequestInfo, init?: RequestInit): Promise; + }); } TimeoutId::Generator timeoutIdGenerator; diff --git a/src/workerd/api/html-rewriter.h b/src/workerd/api/html-rewriter.h index 382d496c8de..3cb4b296653 100644 --- a/src/workerd/api/html-rewriter.h +++ b/src/workerd/api/html-rewriter.h @@ -57,6 +57,13 @@ class HTMLRewriter: public jsg::Object { jsg::Optional text; JSG_STRUCT(element, comments, text); + + JSG_STRUCT_TS_OVERRIDE({ + element?(element: Element): void | Promise; + comments?(comment: Comment): void | Promise; + text?(element: Text): void | Promise; + }); + // Specify parameter types for callback functions }; struct DocumentContentHandlers { @@ -69,6 +76,14 @@ class HTMLRewriter: public jsg::Object { jsg::Optional end; JSG_STRUCT(doctype, comments, text, end); + + JSG_STRUCT_TS_OVERRIDE({ + doctype?(doctype: Doctype): void | Promise; + comments?(comment: Comment): void | Promise; + text?(text: Text): void | Promise; + end?(end: DocumentEnd): void | Promise; + }); + // Specify parameter types for callback functions }; jsg::Ref on(kj::String selector, ElementContentHandlers&& handlers); @@ -113,6 +128,12 @@ class HTMLRewriter: public jsg::Object { // // The Element content token also exposes an AttributesIterator type. This is not a content token // per se, but follows the same scoping rule. +// +// Note, when generating TypeScript types, definitions to include are collected before overrides are +// applied. Because ElementCallbackFunction's parameter is always jsg::Ref and not the +// token type, we would not include token types by default as these are only defined in overrides. +// Therefore, we manually define each token type as a JSG_TS_ROOT(), so it gets visited when +// collecting definitions. class HTMLRewriter::Token: public jsg::Object { public: @@ -190,6 +211,20 @@ class Element final: public HTMLRewriter::Token { JSG_METHOD(removeAndKeepContent); JSG_METHOD(setInnerContent); JSG_METHOD(onEndTag); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + before(content: string, options?: ContentOptions): Element; + after(content: string, options?: ContentOptions): Element; + prepend(content: string, options?: ContentOptions): Element; + append(content: string, options?: ContentOptions): Element; + replace(content: string, options?: ContentOptions): Element; + setInnerContent(content: string, options?: ContentOptions): Element; + + onEndTag(handler: (tag: EndTag) => void | Promise): void; + }); + // Require content to be a string, and specify parameter type for onEndTag + // callback function } private: @@ -258,6 +293,13 @@ class EndTag final: public HTMLRewriter::Token { JSG_METHOD(before); JSG_METHOD(after); JSG_METHOD(remove); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + before(content: string, options?: ContentOptions): EndTag; + after(content: string, options?: ContentOptions): EndTag; + }); + // Require content to be a string } private: @@ -290,6 +332,14 @@ class Comment final: public HTMLRewriter::Token { JSG_METHOD(after); JSG_METHOD(replace); JSG_METHOD(remove); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + before(content: string, options?: ContentOptions): Comment; + after(content: string, options?: ContentOptions): Comment; + replace(content: string, options?: ContentOptions): Comment; + }); + // Require content to be a string } private: @@ -324,6 +374,14 @@ class Text final: public HTMLRewriter::Token { JSG_METHOD(after); JSG_METHOD(replace); JSG_METHOD(remove); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + before(content: string, options?: ContentOptions): Text; + after(content: string, options?: ContentOptions): Text; + replace(content: string, options?: ContentOptions): Text; + }); + // Require content to be a string } private: @@ -346,6 +404,8 @@ class Doctype final: public HTMLRewriter::Token { JSG_READONLY_INSTANCE_PROPERTY(name, getName); JSG_READONLY_INSTANCE_PROPERTY(publicId, getPublicId); JSG_READONLY_INSTANCE_PROPERTY(systemId, getSystemId); + + JSG_TS_ROOT(); } private: @@ -364,6 +424,12 @@ class DocumentEnd final: public HTMLRewriter::Token { JSG_RESOURCE_TYPE(DocumentEnd) { JSG_METHOD(append); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + append(content: string, options?: ContentOptions): DocumentEnd; + }); + // Require content to be a string } private: diff --git a/src/workerd/api/http.h b/src/workerd/api/http.h index a5e0a9176e6..8530b3160cc 100644 --- a/src/workerd/api/http.h +++ b/src/workerd/api/http.h @@ -123,6 +123,19 @@ class Headers: public jsg::Object { JSG_METHOD(values); JSG_ITERABLE(entries); + + JSG_TS_DEFINE(type HeadersInit = Headers | Iterable> | Record); + // All type aliases get inlined when exporting RTTI, but this type alias is included by + // the official TypeScript types, so users might be depending on it. + + JSG_TS_OVERRIDE({ + constructor(init?: HeadersInit); + + entries(): IterableIterator<[key: string, value: string]>; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; + + forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; + }); } private: @@ -302,6 +315,12 @@ class Body: public jsg::Object { JSG_METHOD(json); JSG_METHOD(formData); JSG_METHOD(blob); + + JSG_TS_DEFINE(type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData); + // All type aliases get inlined when exporting RTTI, but this type alias is included by + // the official TypeScript types, so users might be depending on it. + JSG_TS_OVERRIDE({ json(): Promise; }); + // Allow JSON body type to be specified } protected: @@ -417,6 +436,14 @@ class Fetcher: public jsg::Object { JSG_METHOD(get); JSG_METHOD(put); JSG_METHOD_NAMED(delete, delete_); + + JSG_TS_OVERRIDE({ + fetch(input: RequestInfo, init?: RequestInit): Promise; + get: never; + put: never; + delete: never; + }); + // Add URL to `fetch` input, and omit method helpers from definition } private: @@ -494,6 +521,10 @@ struct RequestInitializerDict { JSG_STRUCT(method, headers, body, redirect, fetcher, cf, mode, credentials, cache, referrer, referrerPolicy, integrity, signal, observe); + JSG_STRUCT_TS_OVERRIDE(RequestInit { + headers?: HeadersInit; + body?: BodyInit | null; + }); }; class Request: public Body { @@ -622,6 +653,12 @@ class Request: public Body { JSG_READONLY_INSTANCE_PROPERTY(integrity, getIntegrity); JSG_READONLY_INSTANCE_PROPERTY(cache, getCache); } + + JSG_TS_DEFINE(type RequestInfo = Request | string | URL); + // All type aliases get inlined when exporting RTTI, but this type alias is included by + // the official TypeScript types, so users might be depending on it. + JSG_TS_OVERRIDE({ constructor(input: RequestInfo, init?: RequestInit); }); + // Use `RequestInfo` and `RequestInit` type aliases in constructor instead of inlining } private: @@ -682,6 +719,10 @@ class Response: public Body { jsg::Optional encodeBody; JSG_STRUCT(status, statusText, headers, cf, webSocket, encodeBody); + JSG_STRUCT_TS_OVERRIDE(ResponseInit { + headers?: HeadersInit; + encodeBody?: "automatic" | "manual"; + }); }; using Initializer = kj::OneOf>; @@ -801,6 +842,9 @@ class Response: public Body { JSG_READONLY_INSTANCE_PROPERTY(type, getType); JSG_READONLY_INSTANCE_PROPERTY(useFinalUrl, getUseFinalUrl); } + + JSG_TS_OVERRIDE({ constructor(body?: BodyInit | null, init?: ResponseInit); }); + // Use `BodyInit` and `ResponseInit` type aliases in constructor instead of inlining } private: diff --git a/src/workerd/api/kv.h b/src/workerd/api/kv.h index 554cea8d91c..8ffd58ef8eb 100644 --- a/src/workerd/api/kv.h +++ b/src/workerd/api/kv.h @@ -30,6 +30,9 @@ class KvNamespace: public jsg::Object { jsg::Optional cacheTtl; JSG_STRUCT(type, cacheTtl); + JSG_STRUCT_TS_OVERRIDE(KVNamespaceGetOptions { + type: Type; + }); }; using GetResult = kj::Maybe< @@ -43,7 +46,12 @@ class KvNamespace: public jsg::Object { struct GetWithMetadataResult { GetResult value; kj::Maybe metadata; + JSG_STRUCT(value, metadata); + JSG_STRUCT_TS_OVERRIDE(KVNamespaceGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; + }); }; jsg::Promise getWithMetadata( @@ -57,6 +65,7 @@ class KvNamespace: public jsg::Object { jsg::Optional> cursor; JSG_STRUCT(limit, prefix, cursor); + JSG_STRUCT_TS_OVERRIDE(KVNamespaceListOptions); }; jsg::Promise list(jsg::Lock& js, jsg::Optional options); @@ -70,6 +79,7 @@ class KvNamespace: public jsg::Object { jsg::Optional> metadata; JSG_STRUCT(expiration, expirationTtl, metadata); + JSG_STRUCT_TS_OVERRIDE(KVNamespacePutOptions); }; using PutBody = kj::OneOf>; @@ -95,6 +105,49 @@ class KvNamespace: public jsg::Object { JSG_METHOD(put); JSG_METHOD(getWithMetadata); JSG_METHOD_NAMED(delete, delete_); + + JSG_TS_ROOT(); + + JSG_TS_DEFINE( + interface KVNamespaceListKey { + name: Key; + expiration?: number; + metadata?: Metadata; + } + type KVNamespaceListResult = + | { list_complete: false; keys: KVNamespaceListKey[]; cursor: string; } + | { list_complete: true; keys: KVNamespaceListKey[]; }; + ); + // `Metadata` before `Key` type parameter for backwards-compatibility with `workers-types@3`. + // `Key` is also an optional type parameter, which must come after required parameters. + + JSG_TS_OVERRIDE(KVNamespace { + get(key: Key, options?: Partial>): Promise; + get(key: Key, type: "text"): Promise; + get(key: Key, type: "json"): Promise; + get(key: Key, type: "arrayBuffer"): Promise; + get(key: Key, type: "stream"): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"text">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"json">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"arrayBuffer">): Promise; + get(key: Key, options?: KVNamespaceGetOptions<"stream">): Promise; + + list(options?: KVNamespaceListOptions): Promise>; + + put(key: Key, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVNamespacePutOptions): Promise; + + getWithMetadata(key: Key, options?: Partial>): Promise>; + getWithMetadata(key: Key, type: "text"): Promise>; + getWithMetadata(key: Key, type: "json"): Promise>; + getWithMetadata(key: Key, type: "arrayBuffer"): Promise>; + getWithMetadata(key: Key, type: "stream"): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"text">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"json">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"arrayBuffer">): Promise>; + getWithMetadata(key: Key, options: KVNamespaceGetOptions<"stream">): Promise>; + + delete(key: Key): Promise; + }); } protected: diff --git a/src/workerd/api/r2-bucket.h b/src/workerd/api/r2-bucket.h index 81908074764..08840c5999b 100644 --- a/src/workerd/api/r2-bucket.h +++ b/src/workerd/api/r2-bucket.h @@ -40,6 +40,11 @@ class R2Bucket: public jsg::Object { jsg::Optional suffix; JSG_STRUCT(offset, length, suffix); + JSG_STRUCT_TS_OVERRIDE(type R2Range = + | { offset: number; length?: number } + | { offset?: number; length: number } + | { suffix: number } + ); }; struct Conditional { @@ -50,6 +55,7 @@ class R2Bucket: public jsg::Object { jsg::Optional secondsGranularity; JSG_STRUCT(etagMatches, etagDoesNotMatch, uploadedBefore, uploadedAfter, secondsGranularity); + JSG_STRUCT_TS_OVERRIDE(R2Conditional); }; struct GetOptions { @@ -57,6 +63,7 @@ class R2Bucket: public jsg::Object { jsg::Optional>> range; JSG_STRUCT(onlyIf, range); + JSG_STRUCT_TS_OVERRIDE(R2GetOptions); }; struct StringChecksums { @@ -67,6 +74,7 @@ class R2Bucket: public jsg::Object { jsg::Optional sha512; JSG_STRUCT(md5, sha1, sha256, sha384, sha512); + JSG_STRUCT_TS_OVERRIDE(R2StringChecksums); }; class Checksums: public jsg::Object { @@ -99,6 +107,7 @@ class R2Bucket: public jsg::Object { JSG_LAZY_READONLY_INSTANCE_PROPERTY(sha384, getSha384); JSG_LAZY_READONLY_INSTANCE_PROPERTY(sha512, getSha512); JSG_METHOD(toJSON); + JSG_TS_OVERRIDE(R2Checksums); } jsg::Optional> md5; @@ -120,6 +129,7 @@ class R2Bucket: public jsg::Object { JSG_STRUCT(contentType, contentLanguage, contentDisposition, contentEncoding, cacheControl, cacheExpiry); + JSG_STRUCT_TS_OVERRIDE(R2HTTPMetadata); HttpMetadata clone() const; }; @@ -135,6 +145,7 @@ class R2Bucket: public jsg::Object { jsg::Optional, jsg::NonCoercible>> sha512; JSG_STRUCT(onlyIf, httpMetadata, customMetadata, md5, sha1, sha256, sha384, sha512); + JSG_STRUCT_TS_OVERRIDE(R2PutOptions); }; class HeadResult: public jsg::Object { @@ -187,6 +198,7 @@ class R2Bucket: public jsg::Object { JSG_LAZY_READONLY_INSTANCE_PROPERTY(customMetadata, getCustomMetadata); JSG_LAZY_READONLY_INSTANCE_PROPERTY(range, getRange); JSG_METHOD(writeHttpMetadata); + JSG_TS_OVERRIDE(R2Object); } protected: @@ -235,6 +247,9 @@ class R2Bucket: public jsg::Object { JSG_METHOD(text); JSG_METHOD(json); JSG_METHOD(blob); + JSG_TS_OVERRIDE(R2ObjectBody { + json(): Promise; + }); } private: jsg::Ref body; @@ -247,6 +262,7 @@ class R2Bucket: public jsg::Object { kj::Array delimitedPrefixes; JSG_STRUCT(objects, truncated, cursor, delimitedPrefixes); + JSG_STRUCT_TS_OVERRIDE(R2Objects); }; struct ListOptions { @@ -258,6 +274,11 @@ class R2Bucket: public jsg::Object { jsg::Optional>> include; JSG_STRUCT(limit, prefix, cursor, delimiter, startAfter, include); + JSG_STRUCT_TS_OVERRIDE(type R2ListOptions = never); + // Delete the auto-generated ListOptions definition, we instead define it + // with R2Bucket so we can access compatibility flags. Note, even though + // we're deleting the definition, all definitions will still be renamed + // from `R2BucketListOptions` to `R2ListOptions`. }; jsg::Promise>> head(jsg::Lock& js, kj::String key, @@ -274,12 +295,43 @@ class R2Bucket: public jsg::Object { jsg::Promise list(jsg::Lock& js, jsg::Optional options, const jsg::TypeHandler>& errorType); - JSG_RESOURCE_TYPE(R2Bucket) { + JSG_RESOURCE_TYPE(R2Bucket, CompatibilityFlags::Reader flags) { JSG_METHOD(head); JSG_METHOD(get); JSG_METHOD(put); JSG_METHOD_NAMED(delete, delete_); JSG_METHOD(list); + + JSG_TS_ROOT(); + JSG_TS_OVERRIDE({ + get(key: string, options: R2GetOptions & { onlyIf: R2BucketConditional | Headers }): Promise; + get(key: string, options?: R2GetOptions): Promise; + + put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise; + }); + // Exclude `R2Object` from `get` return type if `onlyIf` not specified, and exclude `null` from `put` return type + + // Rather than using the auto-generated R2ListOptions definition, we define + // it here so we can access compatibility flags from JSG_RESOURCE_TYPE. + if (flags.getR2ListHonorIncludeFields()) { + JSG_TS_DEFINE(interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + include?: ("httpMetadata" | "customMetadata")[]; + }); + } else { + JSG_TS_DEFINE(interface R2ListOptions { + limit?: number; + prefix?: string; + cursor?: string; + delimiter?: string; + startAfter?: string; + }); + // Omit `include` field if compatibility flag disabled as ignored + } } struct UnwrappedConditional { diff --git a/src/workerd/api/r2-rpc.h b/src/workerd/api/r2-rpc.h index 47f12523c74..1c341d8c966 100644 --- a/src/workerd/api/r2-rpc.h +++ b/src/workerd/api/r2-rpc.h @@ -32,6 +32,8 @@ class R2Error: public jsg::Object { JSG_READONLY_INSTANCE_PROPERTY(stack, getStack); // See getStack in dom-exception.h + + JSG_TS_ROOT(); } private: diff --git a/src/workerd/api/streams/common.h b/src/workerd/api/streams/common.h index da4bfe4aae2..5d81d4514b2 100644 --- a/src/workerd/api/streams/common.h +++ b/src/workerd/api/streams/common.h @@ -37,6 +37,10 @@ struct ReadResult { bool done; JSG_STRUCT(value, done); + JSG_STRUCT_TS_OVERRIDE(type ReadableStreamReadResult = + | { done: false, value: R; } + | { done: true; value?: undefined; } + ); void visitForGc(jsg::GcVisitor& visitor) { visitor.visit(value); @@ -50,6 +54,7 @@ struct PipeToOptions { jsg::Optional> signal; JSG_STRUCT(preventClose, preventAbort, preventCancel, signal); + JSG_STRUCT_TS_OVERRIDE(StreamPipeOptions); // An additional, internal only property that is used to indicate // when the pipe operation is used for a pipeThrough rather than diff --git a/src/workerd/api/streams/compression.h b/src/workerd/api/streams/compression.h index 9ad4d391ce7..3ad54200cc3 100644 --- a/src/workerd/api/streams/compression.h +++ b/src/workerd/api/streams/compression.h @@ -17,6 +17,10 @@ class CompressionStream: public TransformStream { JSG_RESOURCE_TYPE(CompressionStream) { JSG_INHERIT(TransformStream); + + JSG_TS_OVERRIDE(extends TransformStream { + constructor(format: "gzip" | "deflate"); + }); } }; @@ -28,6 +32,10 @@ class DecompressionStream: public TransformStream { JSG_RESOURCE_TYPE(DecompressionStream) { JSG_INHERIT(TransformStream); + + JSG_TS_OVERRIDE(extends TransformStream { + constructor(format: "gzip" | "deflate"); + }); } }; diff --git a/src/workerd/api/streams/encoding.h b/src/workerd/api/streams/encoding.h index 2c9991141f2..4d98707e6d3 100644 --- a/src/workerd/api/streams/encoding.h +++ b/src/workerd/api/streams/encoding.h @@ -20,6 +20,8 @@ class TextEncoderStream: public TransformStream { JSG_RESOURCE_TYPE(TextEncoderStream) { JSG_INHERIT(TransformStream); + + JSG_TS_OVERRIDE(extends TransformStream); } }; @@ -41,6 +43,8 @@ class TextDecoderStream: public TransformStream { JSG_RESOURCE_TYPE(TextDecoderStream) { JSG_INHERIT(TransformStream); + + JSG_TS_OVERRIDE(extends TransformStream); } }; diff --git a/src/workerd/api/streams/readable.h b/src/workerd/api/streams/readable.h index 27b7fd78267..2478cae82bb 100644 --- a/src/workerd/api/streams/readable.h +++ b/src/workerd/api/streams/readable.h @@ -81,6 +81,10 @@ class ReadableStreamDefaultReader : public jsg::Object, JSG_METHOD(cancel); JSG_METHOD(read); JSG_METHOD(releaseLock); + + JSG_TS_OVERRIDE( { + read(): Promise>; + }); } // Internal API @@ -142,6 +146,11 @@ class ReadableStreamBYOBReader: public jsg::Object, JSG_METHOD(readAtLeast); // Non-standard extension that should only apply to BYOB byte streams. + + JSG_TS_OVERRIDE(ReadableStreamBYOBReader { + read(view: T): Promise>; + readAtLeast(minElements: number, view: T): Promise>; + }); } // Internal API @@ -227,6 +236,10 @@ class ReadableStream: public jsg::Object { jsg::Optional mode; // can be "byob" or undefined JSG_STRUCT(mode); + + JSG_STRUCT_TS_OVERRIDE({ mode: "byob" }); + // Intentionally required, so we can use `GetReaderOptions` directly in the + // `ReadableStream#getReader()` overload. }; Reader getReader(jsg::Lock& js, jsg::Optional options); @@ -249,6 +262,10 @@ class ReadableStream: public jsg::Object { jsg::Ref readable; JSG_STRUCT(writable, readable); + JSG_STRUCT_TS_OVERRIDE(ReadableWritablePair { + readable: ReadableStream; + writable: WritableStream; + }); }; jsg::Ref pipeThrough( @@ -279,6 +296,29 @@ class ReadableStream: public jsg::Object { JSG_METHOD(values); JSG_ASYNC_ITERABLE(values); + + JSG_TS_DEFINE(interface ReadableStream { + cancel(reason?: any): Promise; + + getReader(): ReadableStreamDefaultReader; + getReader(options: ReadableStreamGetReaderOptions): ReadableStreamBYOBReader; + + pipeThrough(transform: ReadableWritablePair, options?: StreamPipeOptions): ReadableStream; + pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; + + tee(): [ReadableStream, ReadableStream]; + + values(options?: ReadableStreamValuesOptions): AsyncIterableIterator; + [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; + }); + JSG_TS_OVERRIDE(const ReadableStream: { + prototype: ReadableStream; + new (underlyingSource: UnderlyingByteSource, strategy?: QueuingStrategy): ReadableStream; + new (underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream; + }); + // Replace ReadableStream class with an interface and const, so we can have + // two constructors with differing type parameters for byte-oriented and + // value-oriented streams. } private: @@ -315,6 +355,11 @@ class ByteLengthQueuingStrategy: public jsg::Object { JSG_RESOURCE_TYPE(ByteLengthQueuingStrategy) { JSG_READONLY_PROTOTYPE_PROPERTY(highWaterMark, getHighWaterMark); JSG_READONLY_PROTOTYPE_PROPERTY(size, getSize); + + JSG_TS_OVERRIDE(implements QueuingStrategy { + get size(): (chunk?: any) => number; + }); + // QueuingStrategy requires the result of the size function to be defined } private: @@ -340,6 +385,11 @@ class CountQueuingStrategy: public jsg::Object { JSG_RESOURCE_TYPE(CountQueuingStrategy) { JSG_READONLY_PROTOTYPE_PROPERTY(highWaterMark, getHighWaterMark); JSG_READONLY_PROTOTYPE_PROPERTY(size, getSize); + + JSG_TS_OVERRIDE(implements QueuingStrategy { + get size(): (chunk?: any) => number; + }); + // QueuingStrategy requires the result of the size function to be defined } private: diff --git a/src/workerd/api/streams/standard.h b/src/workerd/api/streams/standard.h index 33560db385a..e4e9451299a 100644 --- a/src/workerd/api/streams/standard.h +++ b/src/workerd/api/streams/standard.h @@ -24,6 +24,9 @@ struct StreamQueuingStrategy { jsg::Optional> size; JSG_STRUCT(highWaterMark, size); + JSG_STRUCT_TS_OVERRIDE(QueuingStrategy { + size?: (chunk: T) => number | bigint; + }); }; struct UnderlyingSource { @@ -59,6 +62,19 @@ struct UnderlyingSource { jsg::Optional> cancel; JSG_STRUCT(type, autoAllocateChunkSize, start, pull, cancel); + JSG_STRUCT_TS_DEFINE(interface UnderlyingByteSource { + type: "bytes"; + autoAllocateChunkSize?: number; + start?: (controller: ReadableByteStreamController) => void | Promise; + pull?: (controller: ReadableByteStreamController) => void | Promise; + cancel?: (reason: any) => void | Promise; + }); + JSG_STRUCT_TS_OVERRIDE( { + type?: "" | undefined; + autoAllocateChunkSize: never; + start?: (controller: ReadableStreamDefaultController) => void | Promise; + pull?: (controller: ReadableStreamDefaultController) => void | Promise; + }); kj::Maybe> maybeTransformer; // The maybeTransformer field here is part of the internal implementation of @@ -82,6 +98,9 @@ struct UnderlyingSink { jsg::Optional> close; JSG_STRUCT(type, start, write, abort, close); + JSG_STRUCT_TS_OVERRIDE( { + write?: (chunk: W, controller: WritableStreamDefaultController) => void | Promise; + }); kj::Maybe> maybeTransformer; // The maybeTransformer field here is part of the internal implementation of @@ -102,6 +121,11 @@ struct Transformer { jsg::Optional> flush; JSG_STRUCT(readableType, writableType, start, transform, flush); + JSG_STRUCT_TS_OVERRIDE( { + start?: (controller: TransformStreamDefaultController) => void | Promise; + transform?: (chunk: I, controller: TransformStreamDefaultController) => void | Promise; + flush?: (controller: TransformStreamDefaultController) => void | Promise; + }); }; // ======================================================================================= @@ -693,6 +717,10 @@ class ReadableStreamDefaultController: public jsg::Object { JSG_METHOD(close); JSG_METHOD(enqueue); JSG_METHOD(error); + + JSG_TS_OVERRIDE( { + enqueue(chunk?: R): void; + }); } private: @@ -1215,6 +1243,10 @@ class TransformStreamDefaultController: public jsg::Object { JSG_METHOD(enqueue); JSG_METHOD(error); JSG_METHOD(terminate); + + JSG_TS_OVERRIDE( { + enqueue(chunk?: O): void; + }); } jsg::Promise write(jsg::Lock& js, v8::Local chunk); diff --git a/src/workerd/api/streams/transform.h b/src/workerd/api/streams/transform.h index 6ac7fb7033e..63ab4f70d97 100644 --- a/src/workerd/api/streams/transform.h +++ b/src/workerd/api/streams/transform.h @@ -46,9 +46,21 @@ class TransformStream: public jsg::Object { if (flags.getJsgPropertyOnPrototypeTemplate()) { JSG_READONLY_PROTOTYPE_PROPERTY(readable, getReadable); JSG_READONLY_PROTOTYPE_PROPERTY(writable, getWritable); + + JSG_TS_OVERRIDE( { + constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); + get readable(): ReadableStream; + get writable(): WritableStream; + }); } else { JSG_READONLY_INSTANCE_PROPERTY(readable, getReadable); JSG_READONLY_INSTANCE_PROPERTY(writable, getWritable); + + JSG_TS_OVERRIDE( { + constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); + readonly readable: ReadableStream; + readonly writable: WritableStream; + }); } } @@ -73,6 +85,8 @@ class IdentityTransformStream: public TransformStream { JSG_RESOURCE_TYPE(IdentityTransformStream) { JSG_INHERIT(TransformStream); + + JSG_TS_OVERRIDE(extends TransformStream); } }; diff --git a/src/workerd/api/streams/writable.h b/src/workerd/api/streams/writable.h index de6be575650..87375e25809 100644 --- a/src/workerd/api/streams/writable.h +++ b/src/workerd/api/streams/writable.h @@ -56,6 +56,10 @@ class WritableStreamDefaultWriter: public jsg::Object, JSG_METHOD(close); JSG_METHOD(write); JSG_METHOD(releaseLock); + + JSG_TS_OVERRIDE( { + write(chunk?: W): Promise; + }); } // Internal API @@ -140,6 +144,10 @@ class WritableStream: public jsg::Object { JSG_METHOD(abort); JSG_METHOD(close); JSG_METHOD(getWriter); + + JSG_TS_OVERRIDE( { + getWriter(): WritableStreamDefaultWriter; + }); } private: diff --git a/src/workerd/api/trace.h b/src/workerd/api/trace.h index 1571a589b11..d4796826e98 100644 --- a/src/workerd/api/trace.h +++ b/src/workerd/api/trace.h @@ -234,6 +234,8 @@ class TraceMetrics final : public jsg::Object { JSG_RESOURCE_TYPE(TraceMetrics) { JSG_READONLY_INSTANCE_PROPERTY(cpuTime, getCPUTime); JSG_READONLY_INSTANCE_PROPERTY(wallTime, getWallTime); + + JSG_TS_ROOT(); } private: uint cpuTime; @@ -246,6 +248,8 @@ class UnsafeTraceMetrics final: public jsg::Object { JSG_RESOURCE_TYPE(UnsafeTraceMetrics) { JSG_METHOD(fromTrace); + + JSG_TS_ROOT(); } }; diff --git a/src/workerd/api/url-standard.h b/src/workerd/api/url-standard.h index fbb1d16500e..ee871d79cf2 100644 --- a/src/workerd/api/url-standard.h +++ b/src/workerd/api/url-standard.h @@ -140,6 +140,14 @@ class URLSearchParams: public jsg::Object { JSG_METHOD(forEach); JSG_METHOD(toString); JSG_ITERABLE(entries); + + JSG_TS_OVERRIDE(URLSearchParams { + entries(): IterableIterator<[key: string, value: string]>; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; + + forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; + }); + // Rename from urlURLSearchParams } private: @@ -295,6 +303,11 @@ class URL: public jsg::Object { JSG_READONLY_PROTOTYPE_PROPERTY(searchParams, getSearchParams); JSG_METHOD_NAMED(toJSON, getHref); JSG_METHOD_NAMED(toString, getHref); + + JSG_TS_OVERRIDE(URL { + constructor(url: string | URL, base?: string | URL); + }); + // Rename from urlURL, and allow URLs which get coerced to strings in either constructor parameter } static bool isSpecialScheme(jsg::UsvStringPtr scheme); diff --git a/src/workerd/api/url.h b/src/workerd/api/url.h index ad5dc5a63ea..db757e826d3 100644 --- a/src/workerd/api/url.h +++ b/src/workerd/api/url.h @@ -95,6 +95,11 @@ class URL: public jsg::Object { JSG_METHOD(toString); JSG_METHOD(toJSON); + + JSG_TS_OVERRIDE({ + constructor(url: string | URL, base?: string | URL); + }); + // Allow URLs which get coerced to strings in either constructor parameter } explicit URL(kj::Url&& u); @@ -169,7 +174,7 @@ class URLSearchParams: public jsg::Object { kj::String toString(); - JSG_RESOURCE_TYPE(URLSearchParams) { + JSG_RESOURCE_TYPE(URLSearchParams, CompatibilityFlags::Reader flags) { JSG_METHOD(append); JSG_METHOD_NAMED(delete, delete_); JSG_METHOD(get); @@ -187,6 +192,26 @@ class URLSearchParams: public jsg::Object { JSG_ITERABLE(entries); JSG_METHOD(toString); + + if (flags.getSpecCompliantUrl()) { + // This is a hack. The non-spec-compliant URLSearchParams type is used in + // the Response and Request constructors. This means that when the + // TypeScript generation scripts are visiting root types for inclusion, + // we'll always visit the non-spec-compliant type even if we have the + // "url-standard" flag enabled. Rather than updating those usages based + // on which flags are enabled, we just delete the non-spec complaint + // declaration in an override if "url-standard" is enabled. + JSG_TS_OVERRIDE(type URLSearchParams = never); + } else { + JSG_TS_OVERRIDE({ + constructor(init?: URLSearchParams | string | Record | [key: string, value: string][]); + + entries(): IterableIterator<[key: string, value: string]>; + [Symbol.iterator](): IterableIterator<[key: string, value: string]>; + + forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; + }); + } } private: diff --git a/src/workerd/api/web-socket.h b/src/workerd/api/web-socket.h index ac45dba0627..ee1768fbb3d 100644 --- a/src/workerd/api/web-socket.h +++ b/src/workerd/api/web-socket.h @@ -21,7 +21,11 @@ class MessageEvent: public Event { struct Initializer { v8::Local data; + JSG_STRUCT(data); + JSG_STRUCT_TS_OVERRIDE(MessageEventInit { + data: ArrayBuffer | string; + }); }; static jsg::Ref constructor( kj::String type, Initializer initializer, v8::Isolate* isolate) { @@ -44,6 +48,12 @@ class MessageEvent: public Event { JSG_READONLY_INSTANCE_PROPERTY(lastEventId, getLastEventId); JSG_READONLY_INSTANCE_PROPERTY(source, getSource); JSG_READONLY_INSTANCE_PROPERTY(ports, getPorts); + + JSG_TS_ROOT(); + // MessageEvent will be referenced from the `WebSocketEventMap` define + JSG_TS_OVERRIDE({ + readonly data: ArrayBuffer | string; + }); } private: @@ -61,7 +71,9 @@ class CloseEvent: public Event { jsg::Optional code; jsg::Optional reason; jsg::Optional wasClean; + JSG_STRUCT(code, reason, wasClean); + JSG_STRUCT_TS_OVERRIDE(CloseEventInit); }; static jsg::Ref constructor(kj::String type, Initializer initializer) { return jsg::alloc(kj::mv(type), @@ -80,6 +92,9 @@ class CloseEvent: public Event { JSG_READONLY_INSTANCE_PROPERTY(code, getCode); JSG_READONLY_INSTANCE_PROPERTY(reason, getReason); JSG_READONLY_INSTANCE_PROPERTY(wasClean, getWasClean); + + JSG_TS_ROOT(); + // CloseEvent will be referenced from the `WebSocketEventMap` define } private: @@ -112,6 +127,9 @@ class ErrorEvent: public Event { JSG_READONLY_INSTANCE_PROPERTY(lineno, getLineno); JSG_READONLY_INSTANCE_PROPERTY(colno, getColno); JSG_READONLY_INSTANCE_PROPERTY(error, getError); + + JSG_TS_ROOT(); + // ErrorEvent will be referenced from the `WebSocketEventMap` define } private: @@ -225,6 +243,14 @@ class WebSocket: public EventTarget { JSG_READONLY_INSTANCE_PROPERTY(protocol, getProtocol); JSG_READONLY_INSTANCE_PROPERTY(extensions, getExtensions); } + + JSG_TS_DEFINE(type WebSocketEventMap = { + close: CloseEvent; + message: MessageEvent; + open: Event; + error: ErrorEvent; + }); + JSG_TS_OVERRIDE(extends EventTarget); } private: @@ -354,6 +380,30 @@ class WebSocketPair: public jsg::Object { // than named instance properties but jsg does not yet have support for that. JSG_READONLY_INSTANCE_PROPERTY(0, getFirst); JSG_READONLY_INSTANCE_PROPERTY(1, getSecond); + + JSG_TS_OVERRIDE(const WebSocketPair: { + new (): { 0: WebSocket; 1: WebSocket }; + }); + // Ensure correct typing with `Object.values()`. + // Without this override, the generated definition will look like: + // + // ```ts + // declare class WebSocketPair { + // constructor(); + // readonly 0: WebSocket; + // readonly 1: WebSocket; + // } + // ``` + // + // Trying to call `Object.values(new WebSocketPair())` will result + // in the following `any` typed values: + // + // ```ts + // const [one, two] = Object.values(new WebSocketPair()); + // // ^? const one: any + // ``` + // + // With this override in place, `one` and `two` will be typed `WebSocket`. } private: diff --git a/src/workerd/jsg/README.md b/src/workerd/jsg/README.md index 6ee5a8182a5..454d2e28e57 100644 --- a/src/workerd/jsg/README.md +++ b/src/workerd/jsg/README.md @@ -1310,3 +1310,170 @@ TODO(soon): TBD [Record]: https://webidl.spec.whatwg.org/#idl-record [Sequence]: https://webidl.spec.whatwg.org/#idl-sequence [USVString]: https://webidl.spec.whatwg.org/#idl-USVString + +## TypeScript + +TypeScript definitions are automatically generated from JSG RTTI using scripts in the `/types` +directory. To control auto-generation, JSG provides 3 macros for use inside a `JSG_RESOURCE_TYPE` +block: `JSG_TS_ROOT`, `JSG_TS_OVERRIDE`, `JSG_TS_DEFINE`. There are also struct variants of each +macro (`JSG_STRUCT_TS_ROOT`, `JSG_STRUCT_TS_OVERRIDE` and `JSG_STRUCT_TS_DEFINE`), that should +be placed adjacent to the `JSG_STRUCT` declaration, inside the same `struct` definition. + +### `JSG_TS_ROOT`/`JSG_STRUCT_TS_ROOT` + +Declares that this type should be considered a "root" for the purposes of automatically generating +TypeScript definitions. All "root" types and their recursively referenced types (e.g. method +parameter/return types, property types, inherits, etc) will be included in the generated +TypeScript. For example, the type `ServiceWorkerGlobalScope` should be a "root", as should any non- +global type that we'd like include (e.g. `ExportedHandler` and capabilities such as `KvNamespace`). + +The reason we even have to define roots in the first place (as opposed to just generating +TypeScript definitions for all isolate types) is that some types should only be included when +certain compatibility flags are enabled. For example, we only want to include the `Navigator` and +spec-compliant URL implementation types if `global_navigator` and `url_standard` are enabled +respectively. + +Note roots are visited before overrides, so if an override references a new type that wasn't +already referenced by the original type or any other rooted type, the referenced type will itself +need to be declared a root (e.g. `HTMLRewriter`'s HTML Content Tokens like `Element`). + +### `JSG_TS_OVERRIDE`/`JSG_STRUCT_TS_OVERRIDE` + +Customises the generated TypeScript definition for this type. This macro accepts a single override +parameter containing a partial TypeScript statement definition. Varargs are accepted so that +overrides can contain `,` outside of balanced brackets. After trimming whitespace from the override, +the following rules are applied: + +> :warning: **WARNING:** there's a _lot_ of magic here, ensure you understand the examples before +> writing overrides + +1. If an override starts with `export `, `declare `, `type `, `abstract `, `class `, `interface `, + `enum `, `const `, `var ` or `function `, the generated definition will be replaced with the + override. If the replacement is a `class`, `enum`, `const`, `var` or `function`, the `declare` + modifier will be added if it's not already present. In the special case that the override is a + `type`-alias to `never`, the generated definition will be deleted. + +2. Otherwise, the override will be converted to a TypeScript class as follows: (where `` is the + unqualified C++ type name of this resource type) + + 1. If an override starts with `extends `, `implements ` or `{`: `class ` will be prepended + 2. If an override starts with `<`: `class ` will be prepended + 3. Otherwise, `class ` will be prepended + + After this, if the override doesn't end with `}`, ` {}` will be appended. + + The class is then parsed and merged with the generated definition as follows: (note that even + though we convert all non-replacement overrides to classes, this type classification is ignored + when merging, classes just support all possible forms of override) + + 1. If the override class has a different name to the generated type, the generated type is + renamed and all references to it are updated. Note that the renaming happens after all + overrides are applied, so you're able to reference original C++ names in other types' + overrides. + 2. If the override class defines type parameters, they are copied to the generated type. + 3. If the override class defines heritage clauses (e.g. `extends`/`implements`), they replace the + generated types'. Notably, these may include type arguments. + 4. If the override class defines members, those are merged with the generated type's members as + follows: + + 1. Members in the override but not in the generated type are inserted at the end + 2. If the override has a member with the same name as a member in the generated type, the + generated member is removed, and the override is inserted instead. Note that static and + instance members belong to different namespaces for the purposes of this comparison. + 3. If an override member property is declared type `never`, it is not inserted, but its + presence may remove the generated member (as per 2). + +Note that overrides can only customise a single definition. To add additional, handwritten, +TypeScript-only definitions, use the `JSG_(STRUCT_)TS_DEFINE` macros. + +These macros can also be called conditionally in `JSG_RESOURCE_TYPE` blocks based on compatibility +flags. To define a compatibility-flag dependent `JSG_STRUCT` override, define a full-type +replacement `struct` override to a `never` type alias (i.e. `JSG_STRUCT_TS_OVERRIDE(type MyStruct = never)`) +to delete the original definition, then use `JSG_TS_DEFINE` in a nearby `JSG_RESOURCE_TYPE` block +to define an `interface` for the `struct` conditionally. + +Here are some example overrides demonstrating these rules: + +- ```ts + KVNamespaceListOptions + ``` + Renames the generated type to `KVNamespaceListOptions` and updates all type references to the new name. + +- ```ts + KVNamespace { + get(key: string, type: "string"): Promise; + get(key: string, type: "arrayBuffer"): Promise; + } + ``` + Renames the generated type to `KVNamespace` (updating all references), and replaces the `get()` method + definition with two overloads. Leaves all other members untouched. + +- ```ts + { json(): Promise } + ``` + Replaces the `json()` method definition so it's generic in type parameter `T`. Leaves all other + members untouched. + +- ```ts + class Body { json(): Promise } + ``` + Because it starts with `class `, this override replaces the entire generated definition with + `declare class Body { json(): Promise }`, removing all other members. + +- ```ts + { + read(): Promise>; + tee(): [ReadableStream, ReadableStream]; + } + ``` + Adds a type parameter `R` which defaults to `any` to the generated type, replacing the `read()` and + `tee()` method definitions, leaving all other members untouched. + +- ```ts + { actorState: never } + ``` + Removes the `actorState` member from the generated type, leaving all other members untouched. + +- ```ts + extends EventTarget + ``` + Adds `WorkerGlobalScopeEventMap` as a type argument to the generated type's heritage. Leaves all + members untouched. + +- ```ts + extends TransformStream { + constructor(format: "gzip" | "deflate"); + } + ``` + Adds `ArrayBuffer | ArrayBufferView` and `Uint8Array` as type arguments to the generated type's + heritage, then replaces the generated constructor definition, leaving all other members untouched. + +- ```ts + const WebSocketPair: { + new (): { 0: WebSocket; 1: WebSocket }; + } + ``` + Replaces the generated `WebSocketPair` definition with `declare const WebSocketPair: { ... };`. + +- ```ts + type ReadableStreamReadResult = + | { done: false; value: R; } + | { done: true; value?: undefined; } + ``` + Replaces the generated `ReadableStreamReadResult` definition with a type alias. + +- ```ts + type TransactionOptions = never + ``` + Removes the generated `TransactionOptions` definition from the output. Any references to + the type will be renamed to `TransactionOptions`. This is useful if you need declare a + compatibility-flag dependent override for a `JSG_STRUCT` in a nearby `JSG_RESOURCE_TYPE`. + +### `JSG_TS_DEFINE`/`JSG_STRUCT_TS_DEFINE` + +Inserts additional TypeScript definitions next to the generated TypeScript definition for this +type. This macro accepts a single define parameter containing one or more TypeScript definitions +(e.g. `interface`s, `class`es, `type` aliases, `const`s, ...). Varargs are accepted so that defines +can contain `,` outside of balanced brackets. This macro can only be used once per +`JSG_RESOURCE_TYPE` block/`JSG_STRUCT` definition. The `declare` modifier will be added to any +`class`, `enum`, `const`, `var` or `function` definitions if it's not already present. diff --git a/src/workerd/jsg/jsg.h b/src/workerd/jsg/jsg.h index 805eaac0e81..56ddeca68e0 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -537,6 +537,73 @@ using HasGetTemplateOverload = decltype( } // Adds reflection to a resource type. See PropertyReflection for usage. +#define JSG_TS_ROOT() \ + registry.registerTypeScriptRoot() +// Use inside a JSG_RESOURCE_TYPE block to declare that this type should be considered a "root" for +// the purposes of automatically generating TypeScript definitions. All "root" types and their +// recursively referenced types (e.g. method parameter/return types, property types, inherits, etc) +// will be included in the generated TypeScript. See the `## TypeScript` section of the JSG README.md +// for more details. + +#define JSG_TS_OVERRIDE(...) \ + do { \ + static const char OVERRIDE[] = JSG_STRING_LITERAL(__VA_ARGS__); \ + registry.template registerTypeScriptOverride(); \ + } while (false) +// Use inside a JSG_RESOURCE_TYPE block to customise the generated TypeScript definition for this type. +// This macro accepts a single override parameter containing a partial TypeScript statement definition. +// Varargs are accepted so that overrides can contain `,` outside of balanced brackets. See the +// `## TypeScript` section of the JSG README.md for many more details and examples. + +#define JSG_TS_DEFINE(...) \ + do { \ + static const char DEFINE[] = JSG_STRING_LITERAL(__VA_ARGS__); \ + registry.template registerTypeScriptDefine(); \ + } while (false) +// Use inside a JSG_RESOURCE_TYPE block to insert additional TypeScript definitions next to the generated +// TypeScript definition for this type. This macro accepts a single define parameter containing one or +// more TypeScript definitions (e.g. interfaces, classes, type aliases, consts, ...). Varargs are accepted +// so that defines can contain `,` outside of balanced brackets. See the `## TypeScript`section of the JSG +// README.md for more details. + +#define JSG_STRUCT_TS_ROOT() \ + static constexpr bool _JSG_STRUCT_TS_ROOT_DO_NOT_USE_DIRECTLY = true +// Like JSG_TS_ROOT but for use with JSG_STRUCT. Should be placed adjacent to the JSG_STRUCT declaration, +// inside the same `struct` definition. See the `## TypeScript` section of the JSG README.md for more +// details. + +#define JSG_STRUCT_TS_OVERRIDE(...) \ + static constexpr char _JSG_STRUCT_TS_OVERRIDE_DO_NOT_USE_DIRECTLY[] = JSG_STRING_LITERAL(__VA_ARGS__) +// Like JSG_TS_OVERRIDE but for use with JSG_STRUCT. Should be placed adjacent to the JSG_STRUCT +// declaration, inside the same `struct` definition. See the `## TypeScript` section of the JSG README.md +// for many more details and examples. + +#define JSG_STRUCT_TS_DEFINE(...) \ + static constexpr char _JSG_STRUCT_TS_DEFINE_DO_NOT_USE_DIRECTLY[] = JSG_STRING_LITERAL(__VA_ARGS__) +// Like JSG_TS_DEFINE but for use with JSG_STRUCT. Should be placed adjacent to the JSG_STRUCT +// declaration, inside the same `struct` definition. See the `## TypeScript`section of the JSG README.md +// for more details. + +namespace { + template + struct HasStructTypeScriptRoot : std::false_type {}; + template + struct HasStructTypeScriptRoot : std::true_type { }; + // true when the T has _JSG_STRUCT_TS_ROOT_DO_NOT_USE_DIRECTLY field generated by JSG_STRUCT_TS_ROOT + + template + struct HasStructTypeScriptOverride : std::false_type {}; + template + struct HasStructTypeScriptOverride : std::true_type { }; + // true when the T has _JSG_STRUCT_TS_OVERRIDE_DO_NOT_USE_DIRECTLY field generated by JSG_STRUCT_TS_OVERRIDE + + template + struct HasStructTypeScriptDefine : std::false_type {}; + template + struct HasStructTypeScriptDefine : std::true_type { }; + // true when the T has _JSG_STRUCT_TS_DEFINE_DO_NOT_USE_DIRECTLY field generated by JSG_STRUCT_TS_DEFINE +} + #define JSG_STRUCT(...) \ static constexpr ::workerd::jsg::JsgKind JSG_KIND KJ_UNUSED = \ ::workerd::jsg::JsgKind::STRUCT; \ @@ -544,6 +611,15 @@ using HasGetTemplateOverload = decltype( template \ static void registerMembers(Registry& registry) { \ JSG_FOR_EACH(JSG_STRUCT_REGISTER_MEMBER, , __VA_ARGS__); \ + if constexpr (::workerd::jsg::HasStructTypeScriptRoot::value) { \ + registry.registerTypeScriptRoot(); \ + } \ + if constexpr (::workerd::jsg::HasStructTypeScriptOverride::value) { \ + registry.template registerTypeScriptOverride(); \ + } \ + if constexpr (::workerd::jsg::HasStructTypeScriptDefine::value) { \ + registry.template registerTypeScriptDefine(); \ + } \ } \ template \ using JsgFieldWrappers = ::workerd::jsg::TypeTuple< \ diff --git a/src/workerd/jsg/resource.h b/src/workerd/jsg/resource.h index 8f70171523c..5fd8a92bbe7 100644 --- a/src/workerd/jsg/resource.h +++ b/src/workerd/jsg/resource.h @@ -829,6 +829,14 @@ struct ResourceTypeBuilder { prototype->Set(isolate, name, typeWrapper.getTemplate(isolate, (Type*)nullptr)); } + inline void registerTypeScriptRoot() { /* only needed for RTTI */ } + + template + inline void registerTypeScriptOverride() { /* only needed for RTTI */ } + + template + inline void registerTypeScriptDefine() { /* only needed for RTTI */ } + private: TypeWrapper& typeWrapper; v8::Isolate* isolate; diff --git a/src/workerd/jsg/rtti-test.c++ b/src/workerd/jsg/rtti-test.c++ index 05173c7eb38..ee883611713 100644 --- a/src/workerd/jsg/rtti-test.c++ +++ b/src/workerd/jsg/rtti-test.c++ @@ -174,7 +174,7 @@ KJ_TEST("resource structure") { KJ_EXPECT(tStructure() == "(name = \"Base\", members = [], " "extends = (intrinsic = (name = \"v8::kIteratorPrototype\")), " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::Base\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::Base\", tsRoot = false)"); KJ_EXPECT(tStructure() == "(name = \"TestResource\", members = [" "(method = (name = \"instanceMethod\", returnType = (voidt = void), args = [(number = (name = \"int\")), (number = (name = \"double\"))], static = false)), " @@ -188,7 +188,7 @@ KJ_TEST("resource structure") { "(constructor = (args = [(maybe = (value = (string = (name = \"kj::String\")), name = \"jsg::Optional\"))]))], " "extends = (structure = (name = \"Base\", fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::Base\")), " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestResource\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestResource\", tsRoot = false)"); } struct TestNested : jsg::Object { @@ -202,12 +202,13 @@ KJ_TEST("nested structure") { "name = \"Base\", members = [], " "extends = (intrinsic = (name = \"v8::kIteratorPrototype\")), " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::Base\"" + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::Base\", " + "tsRoot = false" "), " "name = \"Base\"))" "], " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestNested\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestNested\", tsRoot = false)"); } struct TestConstant : jsg::Object { @@ -228,7 +229,8 @@ KJ_TEST("constant members") { "(constant = (name = \"ENABLED\", value = 1)), " "(constant = (name = \"CIRCLE\", value = 2))], " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestConstant\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestConstant\", " + "tsRoot = false)"); } struct TestStruct { @@ -246,7 +248,8 @@ KJ_TEST("struct structure") { "(property = (name = \"a\", type = (number = (name = \"int\")), readonly = false, lazy = false, prototype = false)), " "(property = (name = \"b\", type = (boolt = void), readonly = false, lazy = false, prototype = false))], " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestStruct\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestStruct\", " + "tsRoot = false)"); } struct TestSymbolTable: public jsg::Object { @@ -268,12 +271,51 @@ KJ_TEST("symbol table") { "(method = (name = \"acceptResource\", returnType = (voidt = void), args = [(structure = (name = \"TestResource\", fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestResource\"))], static = false)), " "(method = (name = \"recursiveTypeFunction\", returnType = (voidt = void), args = [(structure = (name = \"TestSymbolTable\", fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestSymbolTable\"))], static = false))], " "iterable = false, asyncIterable = false, " - "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestSymbolTable\")"); + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestSymbolTable\", " + "tsRoot = false)"); KJ_EXPECT(builder.structure("workerd::jsg::rtti::(anonymous namespace)::TestSymbolTable"_kj) != nullptr); KJ_EXPECT(builder.structure("workerd::jsg::rtti::(anonymous namespace)::TestResource"_kj) != nullptr); KJ_EXPECT(KJ_REQUIRE_NONNULL(builder.structure("workerd::jsg::rtti::(anonymous namespace)::TestResource"_kj)).getMembers().size() > 0); } +struct TestTypeScriptResourceType: public jsg::Object { + int getThing() { return 42; } + + JSG_RESOURCE_TYPE(TestTypeScriptResourceType) { + JSG_READONLY_INSTANCE_PROPERTY(thing, getThing); + + JSG_TS_ROOT(); + JSG_TS_DEFINE(interface Define {}); + JSG_TS_OVERRIDE({ readonly thing: 42 }); + }; +}; + +struct TestTypeScriptStruct { + int structThing; + JSG_STRUCT(structThing); + + JSG_STRUCT_TS_ROOT(); + JSG_STRUCT_TS_DEFINE(interface StructDefine {}); + JSG_STRUCT_TS_OVERRIDE(RenamedStructThing { structThing: 42 }); +}; + +KJ_TEST("typescript macros") { + KJ_EXPECT(tStructure() == "(name = \"TestTypeScriptResourceType\", members = [" + "(property = (name = \"thing\", type = (number = (name = \"int\")), readonly = true, lazy = false, prototype = false))], " + "iterable = false, asyncIterable = false, " + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestTypeScriptResourceType\", " + "tsRoot = true, " + "tsOverride = \"{ readonly thing: 42 }\", " + "tsDefine = \"interface Define {}\")"); + KJ_EXPECT(tStructure() == "(name = \"TestTypeScriptStruct\", members = [" + "(property = (name = \"structThing\", type = (number = (name = \"int\")), readonly = false, lazy = false, prototype = false))], " + "iterable = false, asyncIterable = false, " + "fullyQualifiedName = \"workerd::jsg::rtti::(anonymous namespace)::TestTypeScriptStruct\", " + "tsRoot = true, " + "tsOverride = \"RenamedStructThing { structThing: 42 }\", " + "tsDefine = \"interface StructDefine {}\")"); +} + } // namespace } // namespace workerd::jsg::rtti diff --git a/src/workerd/jsg/rtti.capnp b/src/workerd/jsg/rtti.capnp index d71812bae5c..033f051456a 100644 --- a/src/workerd/jsg/rtti.capnp +++ b/src/workerd/jsg/rtti.capnp @@ -198,6 +198,20 @@ struct Structure { # true if the structure is async iterable asyncIterator @7 :Method; # Method returning async iterator if the structure is async iterable + + tsRoot @8 :Bool; + # See `JSG_TS_ROOT`'s documentation in the `## TypeScript` section of the JSG README.md. + # If `JSG_(STRUCT_)TS_ROOT` is declared for a type, this value will be `true`. + + tsOverride @9 :Text; + # See `JSG_TS_OVERRIDE`'s documentation in the `## TypeScript` section of the JSG README.md. + # If `JSG_(STRUCT_)TS_OVERRIDE` is declared for a type, this value will be the contents of the + # macro declaration verbatim. + + tsDefine @10 :Text; + # See `JSG_TS_DEFINE`'s documentation in the `## TypeScript` section of the JSG README.md. + # If `JSG_(STRUCT_)TS_DEFINE` is declared for a type, this value will be the contents of the + # macro declaration verbatim. } struct Member { diff --git a/src/workerd/jsg/rtti.h b/src/workerd/jsg/rtti.h index eb2ef5cd1d2..5fbc7b07394 100644 --- a/src/workerd/jsg/rtti.h +++ b/src/workerd/jsg/rtti.h @@ -517,6 +517,14 @@ struct MemberCounter { template inline void registerStaticMethod() { ++count; } + inline void registerTypeScriptRoot() { /* not a member */ } + + template + inline void registerTypeScriptOverride() { /* not a member */ } + + template + inline void registerTypeScriptDefine() { /* not a member */ } + size_t count = 0; }; @@ -656,6 +664,20 @@ struct MembersBuilder { using Args = typename Traits::ArgsTuple; TupleRttiBuilder::build(method.initArgs(std::tuple_size_v), rtti); } + + inline void registerTypeScriptRoot() { + structure.setTsRoot(true); + } + + template + inline void registerTypeScriptOverride() { + structure.setTsOverride(tsOverride); + } + + template + inline void registerTypeScriptDefine() { + structure.setTsDefine(tsDefine); + } }; template diff --git a/types/src/generator/index.ts b/types/src/generator/index.ts index 1561e852f70..33a6a63c978 100644 --- a/types/src/generator/index.ts +++ b/types/src/generator/index.ts @@ -12,6 +12,8 @@ import { import ts from "typescript"; import { createStructureNode } from "./structure"; +export { getTypeName } from "./type"; + type StructureMap = Map; // Builds a lookup table mapping type names to structures function collectStructureMap(root: StructureGroups): StructureMap { @@ -24,26 +26,15 @@ function collectStructureMap(root: StructureGroups): StructureMap { return map; } -// Types to visit in `collectIncluded` for finding types to include -// (global scope and bindings types) -// TODO(soon): replace this with a macro like JSG_TS_ROOT or JSG_TS_BINDING_TYPE -const TYPE_ROOTS = [ - "workerd::api::ServiceWorkerGlobalScope", - "workerd::api::ExportedHandler", - "workerd::api::DurableObjectNamespace", - "workerd::api::AnalyticsEngine", - "workerd::api::KvNamespace", - "workerd::api::public_beta::R2Bucket", -]; - // Builds a set containing the names of structures that should be included // in the definitions, because they are referenced by root types or any of their -// children. +// children. A struct/resource type is marked as a root type using a +// `JSG_(STRUCT_)TS_ROOT` macro. // // We need to do this as some types should only be included in the definitions // when certain compatibility flags are enabled (e.g. `Navigator`, // standards-compliant `URL`). However, these types are always included in -// the *_TYPES macros. +// the `*_TYPES` macros. function collectIncluded(map: StructureMap): Set { const included = new Set(); @@ -73,7 +64,7 @@ function collectIncluded(map: StructureMap): Set { function visitFunction(func: FunctionType | Method) { func.getArgs().forEach(visitType); - return visitType(func.getReturnType()); + visitType(func.getReturnType()); } function visitMember(member: Member) { @@ -105,10 +96,9 @@ function collectIncluded(map: StructureMap): Set { } } - for (const rootName of TYPE_ROOTS) { - const root = map.get(rootName); - assert(root !== undefined, `Unknown root type: ${rootName}`); - visitStructure(root); + // Visit all structures with `JSG_(STRUCT_)TS_ROOT` macros + for (const structure of map.values()) { + if (structure.getTsRoot()) visitStructure(structure); } return included; @@ -154,29 +144,34 @@ export function generateDefinitions(root: StructureGroups): ts.Node[] { const included = collectIncluded(map); const classes = collectClasses(map); + // Record a list of ignored structures to make sure we haven't missed any + // `JSG_TS_ROOT()` macros + const ignored: string[] = []; // Can't use `flatMap()` here as `getGroups()` returns a `capnp.List` const nodes = root.getGroups().map((group) => { const structureNodes: ts.Node[] = []; group.getStructures().forEach((structure) => { const name = structure.getFullyQualifiedName(); + if (included.has(name)) { const asClass = classes.has(name); structureNodes.push(createStructureNode(structure, asClass)); + } else { + ignored.push(name); } }); - // Add group label to first in group - if (structureNodes.length > 0) { - ts.addSyntheticLeadingComment( - structureNodes[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ` ${group.getName()}`, - /* hasTrailingNewLine */ true - ); - } - return structureNodes; }); + // Log ignored types to make sure we didn't forget anything + if (ignored.length > 0) { + console.warn( + "WARNING: The following types were not referenced from any `JSG_TS_ROOT()`ed type and have been omitted from the output. " + + "This could be because of disabled compatibility flags." + ); + for (const name of ignored) console.warn(`- ${name}`); + } + return nodes.flat(); } diff --git a/types/src/generator/structure.ts b/types/src/generator/structure.ts index 5953730b17c..da749516eac 100644 --- a/types/src/generator/structure.ts +++ b/types/src/generator/structure.ts @@ -122,8 +122,10 @@ function createConstantPartial( f.createToken(ts.SyntaxKind.ReadonlyKeyword), ]; const name = constant.getName(); - const value = constant.getValue().valueOf(); - const valueNode = f.createLiteralTypeNode(f.createNumericLiteral(value)); + // Rather than using the constant value as a literal type here, just type them + // as `number` to encourage people to use them as constants. This is also what + // TypeScript does in its official lib types. + const valueNode = f.createTypeReferenceNode("number"); return [modifiers, name, valueNode]; } diff --git a/types/src/generator/type.ts b/types/src/generator/type.ts index fadeefac649..96c404747d9 100644 --- a/types/src/generator/type.ts +++ b/types/src/generator/type.ts @@ -79,11 +79,13 @@ function isIterable(array: ArrayType) { // Returns `true` iff `typeNode` is `never` export function isUnsatisfiable(typeNode: ts.TypeNode) { - return ( + const isNeverTypeReference = ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName) && - typeNode.typeName.escapedText === "never" - ); + typeNode.typeName.text === "never"; + const isNeverKeyword = + ts.isToken(typeNode) && typeNode.kind == ts.SyntaxKind.NeverKeyword; + return isNeverTypeReference || isNeverKeyword; } // Strings to replace in fully-qualified structure names with nothing @@ -270,7 +272,14 @@ export function createTypeNode(type: Type, allowCoercion = false): ts.TypeNode { f.createTypeReferenceNode("ArrayBufferView"), ]); case BuiltinType_Type.KJ_DATE: - return f.createTypeReferenceNode("Date"); + if (allowCoercion) { + return f.createUnionTypeNode([ + f.createTypeReferenceNode("number"), + f.createTypeReferenceNode("Date"), + ]); + } else { + return f.createTypeReferenceNode("Date"); + } case BuiltinType_Type.V8FUNCTION: return f.createTypeReferenceNode("Function"); default: diff --git a/types/src/index.ts b/types/src/index.ts index cd040636bba..6633394e9b3 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -12,8 +12,10 @@ import { generateDefinitions } from "./generator"; import { printNodeList, printer } from "./print"; import { createMemoryProgram } from "./program"; import { + compileOverridesDefines, createGlobalScopeTransformer, createIteratorTransformer, + createOverrideDefineTransformer, } from "./transforms"; const definitionsHeader = `/* eslint-disable */ @@ -24,26 +26,49 @@ function printDefinitions(root: StructureGroups): string { // Generate TypeScript nodes from capnp request const nodes = generateDefinitions(root); - // Build TypeScript program from nodes - const source = printNodeList(nodes); - // TODO(soon): when we switch to outputting a separate file per group, we'll - // need to modify this function to accept multiple source files - // (will probably need `program.getSourceFiles()`) - const [program, sourcePath] = createMemoryProgram(source); - const checker = program.getTypeChecker(); - const sourceFile = program.getSourceFile(sourcePath); + // Assemble partial overrides and defines to valid TypeScript source files + const [sources, replacements] = compileOverridesDefines(root); + // Add source file containing generated nodes + const sourcePath = path.resolve(__dirname, "source.ts"); + let source = printNodeList(nodes); + sources.set(sourcePath, source); + + // Build TypeScript program from source files and overrides. Importantly, + // these are in the same program, so we can use nodes from one in the other. + let program = createMemoryProgram(sources); + let checker = program.getTypeChecker(); + let sourceFile = program.getSourceFile(sourcePath); assert(sourceFile !== undefined); // Run post-processing transforms on program - const result = ts.transform(sourceFile, [ - // TODO(soon): when overrides are implemented, apply renames here + let result = ts.transform(sourceFile, [ + // Run iterator transformer before overrides so iterator-like interfaces are + // still removed if they're replaced in overrides createIteratorTransformer(checker), + createOverrideDefineTransformer(program, replacements), + ]); + assert.strictEqual(result.transformed.length, 1); + + // We need the type checker to respect our updated definitions after applying + // overrides (e.g. to find the correct nodes when traversing heritage), so + // rebuild the program to re-run type checking. + // TODO: is there a way to re-run the type checker on an existing program? + source = printer.printFile(result.transformed[0]); + program = createMemoryProgram(new Map([[sourcePath, source]])); + checker = program.getTypeChecker(); + sourceFile = program.getSourceFile(sourcePath); + assert(sourceFile !== undefined); + + result = ts.transform(sourceFile, [ + // Run global scope transformer after overrides so members added in + // overrides are extracted createGlobalScopeTransformer(checker), // TODO(polish): maybe flatten union types? ]); + assert.strictEqual(result.transformed.length, 1); + // TODO(polish): maybe log diagnostics with `ts.getPreEmitDiagnostics(program, sourceFile)`? // (see https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#a-minimal-compiler) - assert.strictEqual(result.transformed.length, 1); // Print program to string return definitionsHeader + printer.printFile(result.transformed[0]); diff --git a/types/src/program.ts b/types/src/program.ts index d1282701381..71d79306e34 100644 --- a/types/src/program.ts +++ b/types/src/program.ts @@ -1,35 +1,49 @@ import path from "path"; import ts from "typescript"; -export function createMemoryProgram(source: string): [ts.Program, string] { +interface MemorySourceFile { + source: string; + sourceFile: ts.SourceFile; +} + +// Creates a TypeScript program from in-memory source files. Accepts a Map of +// fully-resolved "virtual" paths to source code. +export function createMemoryProgram(sources: Map): ts.Program { const options = ts.getDefaultCompilerOptions(); const host = ts.createCompilerHost(options, true); - const sourcePath = path.resolve(__dirname, "source.ts"); - const sourceFile = ts.createSourceFile( - sourcePath, - source, - ts.ScriptTarget.ESNext, - false, - ts.ScriptKind.TS - ); + const sourceFiles = new Map(); + for (const [sourcePath, source] of sources) { + const sourceFile = ts.createSourceFile( + sourcePath, + source, + ts.ScriptTarget.ESNext, + false, + ts.ScriptKind.TS + ); + sourceFiles.set(sourcePath, { source, sourceFile }); + } // Update compiler host to return in-memory source file function patchHostMethod< K extends "fileExists" | "readFile" | "getSourceFile" - >(key: K, placeholderResult: ReturnType) { + >( + key: K, + placeholderResult: (f: MemorySourceFile) => ReturnType + ) { const originalMethod: (...args: any[]) => any = host[key]; host[key] = (fileName: string, ...args: any[]) => { - if (path.resolve(fileName) === sourcePath) { - return placeholderResult; + const sourceFile = sourceFiles.get(path.resolve(fileName)); + if (sourceFile !== undefined) { + return placeholderResult(sourceFile); } return originalMethod.call(host, fileName, ...args); }; } - patchHostMethod("fileExists", true); - patchHostMethod("readFile", source); - patchHostMethod("getSourceFile", sourceFile); + patchHostMethod("fileExists", () => true); + patchHostMethod("readFile", ({ source }) => source); + patchHostMethod("getSourceFile", ({ sourceFile }) => sourceFile); - const program = ts.createProgram([sourcePath], options, host); - return [program, sourcePath]; + const rootNames = [...sourceFiles.keys()]; + return ts.createProgram(rootNames, options, host); } diff --git a/types/src/transforms/globals.ts b/types/src/transforms/globals.ts index 3279ac14b73..7de663d6f80 100644 --- a/types/src/transforms/globals.ts +++ b/types/src/transforms/globals.ts @@ -42,6 +42,29 @@ export function createGlobalScopeTransformer( }; } +// Copy type nodes everywhere they are referenced +function createInlineVisitor( + ctx: ts.TransformationContext, + inlines: Map +): ts.Visitor { + // If there's nothing to inline, just return identity visitor + if (inlines.size === 0) return (node) => node; + + const visitor: ts.Visitor = (node) => { + // Recursively visit all nodes + node = ts.visitEachChild(node, visitor, ctx); + + // Inline all matching type references + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const inline = inlines.get(node.typeName.text); + if (inline !== undefined) return inline; + } + + return node; + }; + return visitor; +} + export function createGlobalScopeVisitor( ctx: ts.TransformationContext, checker: ts.TypeChecker @@ -75,6 +98,7 @@ export function createGlobalScopeVisitor( ts.isIdentifier(node.name) ) { assert(node.type !== undefined); + // Don't create global nodes for nested types, they'll already be there if (!ts.isTypeQueryNode(node.type)) { const modifiers: ts.Modifier[] = [ ctx.factory.createToken(ts.SyntaxKind.ExportKeyword), @@ -100,18 +124,37 @@ export function createGlobalScopeVisitor( // Called with each class/interface that should have its methods/properties // extracted into global functions/consts. Recursively visits superclasses. function extractGlobalNodes( - node: ts.InterfaceDeclaration | ts.ClassDeclaration + node: ts.InterfaceDeclaration | ts.ClassDeclaration, + typeArgs?: ts.NodeArray ): ts.Node[] { const nodes: ts.Node[] = []; + // If this declaration has type parameters, we'll need to inline them when + // extracting members. + const typeArgInlines = new Map(); + if (node.typeParameters) { + assert( + node.typeParameters.length === typeArgs?.length, + `Expected ${node.typeParameters.length} type argument(s), got ${typeArgs?.length}` + ); + node.typeParameters.forEach((typeParam, index) => { + typeArgInlines.set(typeParam.name.text, typeArgs[index]); + }); + } + const inlineVisitor = createInlineVisitor(ctx, typeArgInlines); + // Recursively extract from all superclasses if (node.heritageClauses !== undefined) { - for (const clause of node.heritageClauses) { + for (let clause of node.heritageClauses) { + // Handle case where type param appears in heritage clause: + // ```ts + // class A {} // ↓ + // class B extends A {} + // class C extends B {} + // ``` + clause = ts.visitNode(clause, inlineVisitor); + for (const superType of clause.types) { - // TODO(soon): when overrides are implemented, superclasses may - // define type parameters (e.g. `EventTarget`). - // In these cases, we'll need to inline these type params in - // extracted definitions. Type parameters are in `superType.typeArguments`. const superTypeSymbol = checker.getSymbolAtLocation( superType.expression ); @@ -123,7 +166,11 @@ export function createGlobalScopeVisitor( ts.isInterfaceDeclaration(superTypeDeclaration) || ts.isClassDeclaration(superTypeDeclaration) ); - nodes.push(...extractGlobalNodes(superTypeDeclaration)); + nodes.push( + // Pass any defined type arguments for inlining in extracted nodes + // (e.g. `...extends EventTarget`). + ...extractGlobalNodes(superTypeDeclaration, superType.typeArguments) + ); } } } @@ -131,7 +178,9 @@ export function createGlobalScopeVisitor( // Extract methods/properties for (const member of node.members) { const maybeNode = maybeExtractGlobalNode(member); - if (maybeNode !== undefined) nodes.push(maybeNode); + if (maybeNode !== undefined) { + nodes.push(ts.visitNode(maybeNode, inlineVisitor)); + } } return nodes; diff --git a/types/src/transforms/index.ts b/types/src/transforms/index.ts index 4620cf07c1d..a0be2533944 100644 --- a/types/src/transforms/index.ts +++ b/types/src/transforms/index.ts @@ -1,2 +1,3 @@ export * from "./globals"; export * from "./iterators"; +export * from "./overrides"; diff --git a/types/src/transforms/overrides/compiler.ts b/types/src/transforms/overrides/compiler.ts new file mode 100644 index 00000000000..0ee022fa70c --- /dev/null +++ b/types/src/transforms/overrides/compiler.ts @@ -0,0 +1,110 @@ +import assert from "assert"; +import path from "path"; +import { StructureGroups } from "@workerd/jsg/rtti.capnp.js"; +import ts from "typescript"; +import { getTypeName } from "../../generator"; + +// If an override matches this RegExp, it will replace the existing definition +const keywordReplace = + /^export |^declare |^type |^abstract |^class |^interface |^enum |^const |^var |^function /; +// If an override matches this RegExp, it will have `class ${name} ` prefixed +const keywordHeritage = /^extends |^implements /; + +function compileOverride( + name: string, + override: string +): [compiled: string, isReplacement: boolean] { + // If this override is a complete type replacement, return it as is + // Examples: + // - `const WebSocketPair: { new (): { 0: WebSocket; 1: WebSocket }; }` + // - `type ReadableStreamReadResult = { done: false, value: R; } | { done: true; value?: undefined; }` + // - `type TransactionOptions = never` (deletes definition) + if (keywordReplace.test(override)) return [override, true]; + + // Fixup overrides, so they can be parsed as TypeScript source files. Whilst + // we convert all overrides to classes, this type classification is ignored + // when merging. Classes just support all possible forms of override (extends, + // implements, constructors, (static) properties/methods). + if (keywordHeritage.test(override) || override.startsWith("{")) { + // Use existing name and type classification, may merge members + // Examples: + // - `extends EventTarget` + // - `extends TransformStream { constructor(format: "gzip" | "deflate"); }` + // - `{ json(): Promise; }` + override = `class ${name} ${override}`; + } else if (override.startsWith("<")) { + // Use existing name and type classification, may merge members + // Examples: + // - ` { read(): Promise>; }` + override = `class ${name}${override}`; + } else { + // Use existing type classification, may rename and merge members + // Examples: + // - `KVNamespaceGetOptions { type: Type; }` + // - `KVNamespaceListOptions` (just rename definition) + // - `WorkerGlobalScope extends EventTarget` + override = `class ${override}`; + } + // Purely heritage and rename overrides don't need to define any members, but + // they still need to be valid classes for parsing. + if (!override.endsWith("}")) { + override = `${override} {}`; + } + + return [override, false]; +} + +const overridesPath = path.resolve(__dirname, "overrides"); +const definesPath = path.resolve(__dirname, "defines"); + +// Converts and collects all overrides and defines as TypeScript source files. +// Also returns a set of definitions that should be replaced by their override. +export function compileOverridesDefines( + root: StructureGroups +): [sources: Map, replacements: Set] { + const sources = new Map(); + // Types that need their definition completely replaced by their override + const replacements = new Set(); + + root.getGroups().forEach((group) => { + group.getStructures().forEach((structure) => { + const name = getTypeName(structure); + const override = structure.getTsOverride().trim(); + const define = structure.getTsDefine().trim(); + + if (override !== "") { + const [compiled, isReplacement] = compileOverride(name, override); + sources.set(path.join(overridesPath, name + ".ts"), compiled); + if (isReplacement) replacements.add(name); + } + if (define !== "") { + sources.set(path.join(definesPath, name + ".ts"), define); + } + }); + }); + + return [sources, replacements]; +} + +// Try to find override for structure using name obtained from `getTypeName()` +export function maybeGetOverride( + program: ts.Program, + name: string +): ts.Statement | undefined { + const sourcePath = path.join(overridesPath, name + ".ts"); + const sourceFile = program.getSourceFile(sourcePath); + if (sourceFile !== undefined) { + assert.strictEqual(sourceFile.statements.length, 1); + return sourceFile.statements[0]; + } +} + +// Try to find defines for structure using name obtained from `getTypeName()` +export function maybeGetDefines( + program: ts.Program, + name: string +): ts.NodeArray | undefined { + const sourcePath = path.join(definesPath, name + ".ts"); + const sourceFile = program.getSourceFile(sourcePath); + return sourceFile?.statements; +} diff --git a/types/src/transforms/overrides/index.ts b/types/src/transforms/overrides/index.ts new file mode 100644 index 00000000000..6c2ec629033 --- /dev/null +++ b/types/src/transforms/overrides/index.ts @@ -0,0 +1,553 @@ +import assert from "assert"; +import ts from "typescript"; +import { isUnsatisfiable } from "../../generator/type"; +import { printNode } from "../../print"; +import { maybeGetDefines, maybeGetOverride } from "./compiler"; + +export { compileOverridesDefines } from "./compiler"; + +// Applies handwritten partial TypeScript overrides to generate types to improve +// output fidelity. Also applies type renames and inserts additional handwritten +// definitions for non-generated types. +// +// See the `JSG_TS_OVERRIDE` macro's documentation in `src/workerd/jsg/jsg.h` +// for a full explanation of override rules and examples. +// +// `compileOverridesDefines()` must be used to compile overrides and defines +// into valid TypeScript source files. These should be included in the *same* +// TypeScript `Program` as the source file being transformed. This `Program`, +// along with the set of types that should be fully-replaced (also returned from +// `compileOverridesDefines()`), should be passed to this transformer factory. +// +// ```ts +// export declare class A { +// thing: string; +// } +// export declare class B { +// get(key: string, type: string): Promise; +// put(key: string, value: string): Promise; +// } +// ``` +// +// ...with the following overrides and defines... +// +// - `A`'s override: `RenamedA { thing: Type; }` +// - `B`'s override: `{ +// get(key: string, type: "text"): Promise; +// get(key: string, type: "arrayBuffer"): Promise; +// }` +// - `B`'s define: `interface C { foo: A; }` +// +// --- transforms to ---> +// +// ```ts +// export declare class RenamedA { +// thing: Type; +// } +// export interface C { +// foo: RenamedA; +// } +// export declare class B { +// get(key: string, type: "text"): Promise; +// get(key: string, type: "arrayBuffer"): Promise; +// put(key: string, value: string): Promise; +// } +// ``` +export function createOverrideDefineTransformer( + program: ts.Program, + replacements: Set +): ts.TransformerFactory { + return (ctx) => { + return (node) => { + const overrideCtx: OverrideTransformContext = { + program, + replacements, + renames: new Map(), + }; + const v1 = createOverrideDefineVisitor(ctx, overrideCtx); + const v2 = createRenameReferencesVisitor(ctx, overrideCtx.renames); + node = ts.visitEachChild(node, v1, ctx); + return ts.visitEachChild(node, v2, ctx); + }; + }; +} + +interface OverrideTransformContext { + program: ts.Program; + replacements: Set; + renames: Map; +} + +// Gets an identifying label for this member, shared between method overloads +function getMemberKey(member: ts.ClassElement | ts.TypeElement): string { + if (ts.isConstructorDeclaration(member)) return "constructor$"; + + const name = member.name; + assert( + name !== undefined, + `Expected named member, got "${printNode(member)}"` + ); + + // Put static and instance members in different namespaces. For example, this + // allows instance methods to be overridden without affecting static methods + // of the same name. + const isStatic = + member.modifiers !== undefined && + hasModifier(member.modifiers, ts.SyntaxKind.StaticKeyword); + const keyNamespace = isStatic ? "static$" : "instance$"; + + if ( + ts.isIdentifier(name) || + ts.isStringLiteral(name) || + ts.isNumericLiteral(name) + ) { + return keyNamespace + name.text; + } + if (ts.isComputedPropertyName(name)) { + const expression = name.expression; + if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)) { + return keyNamespace + expression.text; + } + } + return keyNamespace + printNode(name); +} + +// Groups override members by their identifying labels from `getMemberKey()` +function groupMembersByKey( + members: ts.NodeArray +): Map { + const result = new Map(); + members.forEach((member) => { + const key = getMemberKey(member); + let array = result.get(key); + if (array === undefined) result.set(key, (array = [])); + array.push(member); + }); + return result; +} + +// Returns the index of a member in `members` with the specified `key`, or -1 if +// none exists +function findMemberIndex( + members: Member[], + key: string, + fromIndex = 0 +): number { + return members.findIndex( + (member, index) => fromIndex <= index && getMemberKey(member) === key + ); +} + +// Merges generated members with overrides according to the following rules: +// 1. Members in the override but not in the generated type are inserted +// 2. If an override has the same key as a member in the generated type, the +// generated member is removed, and the override is inserted instead +// 3. If an override member property is declared type `never`, it is not +// inserted, but its presence may remove the generated member (as per 2) +function mergeMembers( + generated: ts.NodeArray, + overrides: ts.NodeArray, + transformer: (member: ts.ClassElement) => Member +): Member[] { + const result = [...generated]; + const grouped = groupMembersByKey(overrides); + for (const [key, overrideMembers] of grouped) { + const filteredOverrideMembers = overrideMembers.filter((member) => { + // Filter out `never` typed properties + if (ts.isPropertyDeclaration(member) && member.type !== undefined) { + return !isUnsatisfiable(member.type); + } + // Include all other members + return true; + }); + // Transform all class elements into the correct member type. If `Member` is + // `ts.ClassElement` already, `transformer` will be the identify function. + const transformedOverrideMembers = filteredOverrideMembers.map(transformer); + + // Try to find index of existing generated member with same key + const index = findMemberIndex(result, key); + if (index === -1) { + // If the member couldn't be found, insert overrides at the end + result.push(...transformedOverrideMembers); + } else { + const member = result[index]; + const nextIndex = findMemberIndex(result, key, index + 1); + if ( + ts.isGetAccessorDeclaration(member) || + ts.isSetAccessorDeclaration(member) + ) { + // If this is a getter/setter, it's possible there's one other + // getter/setter with the same key. + if (nextIndex !== -1) { + // Make sure this other member was a getter/setter + const nextMember = result[nextIndex]; + assert( + ts.isGetAccessorDeclaration(nextMember) || + ts.isSetAccessorDeclaration(nextMember), + `Expected getter/setter, got "${printNode(nextMember)}"` + ); + + // Remove the other getter/setter. Because `nextIndex > index`, we'll + // still be able to `splice(index)` later on. + assert(nextIndex > index); + result.splice(nextIndex, /* deleteCount */ 1); + + // Make sure this was the only other member with this key + const nextNextIndex = findMemberIndex(result, key, nextIndex + 1); + assert(nextNextIndex === -1); + } + } else { + // Otherwise, make sure this was the only generated member with this key + assert(nextIndex === -1); + } + + // Remove the member at that index and replace it with overrides + result.splice(index, /* deleteCount */ 1, ...transformedOverrideMembers); + } + } + return result; +} + +// Converts class members to interface members where possible. Used as a +// transformer when merging override members (which will always be class +// members) into an interface. +function classToTypeElement( + ctx: ts.TransformationContext, + member: ts.ClassElement +): ts.TypeElement { + if (ts.isMethodDeclaration(member)) { + return ctx.factory.createMethodSignature( + member.modifiers, + member.name, + member.questionToken, + member.typeParameters, + member.parameters, + member.type + ); + } + if (ts.isPropertyDeclaration(member)) { + return ctx.factory.createPropertySignature( + member.modifiers, + member.name, + member.questionToken, + member.type + ); + } + if ( + ts.isGetAccessorDeclaration(member) || + ts.isSetAccessorDeclaration(member) || + ts.isIndexSignatureDeclaration(member) + ) { + return member; + } + assert.fail( + `Expected interface-compatible member, got "${printNode(member)}". +You'll need to define a full-replacement override to a "class" if you wish to insert this member (i.e. "JSG_TS_OVERRIDE(class MyClass { })").` + ); +} + +// Finds and applies the override (if any) for a node, returning the new +// potentially overridden node +function applyOverride< + Node extends ts.ClassDeclaration | ts.InterfaceDeclaration +>( + ctx: ts.TransformationContext, + overrideCtx: OverrideTransformContext, + node: Node, + updateDeclaration: (node: Node, override: ts.ClassDeclaration) => Node +): ts.Node { + assert(node.name !== undefined); + const name = node.name.text; + const override = maybeGetOverride(overrideCtx.program, name); + const isReplacement = overrideCtx.replacements.has(name); + + // Full-type replacement may rename type too, so record renames now + if (override !== undefined) { + // If override's name is different to the node's name, rename it later + const overrideIdentifier = maybeGetStatementName(override); + if (overrideIdentifier !== undefined) { + const overrideName = overrideIdentifier.text; + if (name !== overrideName) overrideCtx.renames.set(name, overrideName); + } + } + + if (isReplacement) { + assert(override !== undefined); + return ensureStatementModifiers(ctx, override); + } else if (override !== undefined) { + // Merge override into declaration. Whilst we convert all non-replacement + // overrides to classes, this type classification is ignored when merging. + // Classes just support all possible forms of override. See `./compiler.ts` + // `compileOverride()` for details. + assert(ts.isClassDeclaration(override)); + return updateDeclaration(node, override); + } else { + // No override, so return the node as is + return node; + } +} + +// Apply all overrides, insert defines, and record type renames +function createOverrideDefineVisitor( + ctx: ts.TransformationContext, + overrideCtx: OverrideTransformContext +): ts.Visitor { + // Copies all string and numeric literals. Without this, garbage would be + // inserted in locations of literals instead. + // TODO(soon): work out why this happens, something to do with source ranges + // and invalid source files/programs maybe? + const copyLiteralsVisitor: ts.Visitor = (node) => { + node = ts.visitEachChild(node, copyLiteralsVisitor, ctx); + if (ts.isStringLiteral(node)) { + return ctx.factory.createStringLiteral(node.text); + } + if (ts.isNumericLiteral(node)) { + return ctx.factory.createNumericLiteral(node.text); + } + return node; + }; + + return (node) => { + let defines: ts.NodeArray | undefined; + + if (ts.isClassDeclaration(node) && node.name !== undefined) { + defines = maybeGetDefines(overrideCtx.program, node.name.text); + node = applyOverride(ctx, overrideCtx, node, (node, override) => { + return ctx.factory.updateClassDeclaration( + node, + node.decorators, + node.modifiers, + override.name, + override.typeParameters ?? node.typeParameters, + override.heritageClauses ?? node.heritageClauses, + mergeMembers(node.members, override.members, (member) => member) + ); + }); + } else if (ts.isInterfaceDeclaration(node)) { + defines = maybeGetDefines(overrideCtx.program, node.name.text); + node = applyOverride(ctx, overrideCtx, node, (node, override) => { + assert(override.name !== undefined); + return ctx.factory.updateInterfaceDeclaration( + node, + node.decorators, + node.modifiers, + override.name, + override.typeParameters ?? node.typeParameters, + override.heritageClauses ?? node.heritageClauses, + mergeMembers(node.members, override.members, (member) => + classToTypeElement(ctx, member) + ) + ); + }); + } + + // Process node and defines if defined + node = ts.visitNode(node, copyLiteralsVisitor); + defines = ts.visitNodes(defines, copyLiteralsVisitor); + defines = ts.visitNodes(defines, (node) => + ensureStatementModifiers(ctx, node) + ); + + if (ts.isTypeAliasDeclaration(node) && isUnsatisfiable(node.type)) { + // If node was overridden to `type T = never`, delete it, and just insert + // defines if any + return defines === undefined ? undefined : [...defines]; + } else { + // Otherwise, return potentially overridden node, inserting defines if any + // before node + return defines == undefined ? node : [...defines, node]; + } + }; +} + +// Apply previously-recorded type renames to all type references +function createRenameReferencesVisitor( + ctx: ts.TransformationContext, + renames: Map +): ts.Visitor { + const visitor: ts.Visitor = (node) => { + // Recursively visit all nodes + node = ts.visitEachChild(node, visitor, ctx); + + // Rename all type references + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const rename = renames.get(node.typeName.text); + if (rename !== undefined) { + return ctx.factory.updateTypeReferenceNode( + node, + ctx.factory.createIdentifier(rename), + node.typeArguments + ); + } + } + + // Rename all type queries (e.g. nested types) + if (ts.isTypeQueryNode(node) && ts.isIdentifier(node.exprName)) { + const rename = renames.get(node.exprName.text); + if (rename !== undefined) { + return ctx.factory.updateTypeQueryNode( + node, + ctx.factory.createIdentifier(rename), + node.typeArguments + ); + } + } + + // Rename all expressions with type arguments (e.g. heritage clauses) + if ( + ts.isExpressionWithTypeArguments(node) && + ts.isIdentifier(node.expression) + ) { + const rename = renames.get(node.expression.text); + if (rename !== undefined) { + return ctx.factory.updateExpressionWithTypeArguments( + node, + ctx.factory.createIdentifier(rename), + node.typeArguments + ); + } + } + + return node; + }; + return visitor; +} + +// Returns a statement's identifier if it has one. This is used to get the +// name to rename too (if any), hence we ignore variables and functions, since +// replacing a reference to a type with a referencing to a variable/function is +// an error. +function maybeGetStatementName(node: ts.Statement): ts.Identifier | undefined { + if ( + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isTypeAliasDeclaration(node) + ) { + return node.name; + } +} + +// Checks whether the modifiers array contains a modifier of the specified kind +function hasModifier(modifiers: ts.ModifiersArray, kind: ts.Modifier["kind"]) { + let hasModifier = false; + modifiers?.forEach((modifier) => { + hasModifier ||= modifier.kind === kind; + }); + return hasModifier; +} + +// Ensure a modifiers array has the specified modifier, inserting it at the +// start if it doesn't. +function ensureModifier( + ctx: ts.TransformationContext, + modifiers: ts.ModifiersArray | undefined, + ensure: ts.SyntaxKind.ExportKeyword | ts.SyntaxKind.DeclareKeyword +): ts.ModifiersArray { + // If modifiers already contains the required modifier, return it as is... + if (modifiers !== undefined && hasModifier(modifiers, ensure)) { + return modifiers; + } + // ...otherwise, add the modifier to the start of the array + return ctx.factory.createNodeArray( + [ctx.factory.createToken(ensure), ...(modifiers ?? [])], + modifiers?.hasTrailingComma + ); +} + +// Ensure a modifiers array includes the `export` modifier +function ensureExportModifier( + ctx: ts.TransformationContext, + modifiers: ts.ModifiersArray | undefined +): ts.ModifiersArray { + return ensureModifier(ctx, modifiers, ts.SyntaxKind.ExportKeyword); +} + +// Ensures a modifiers array includes the `export declare` modifiers +function ensureExportDeclareModifiers( + ctx: ts.TransformationContext, + modifiers: ts.ModifiersArray | undefined +): ts.ModifiersArray { + // Call in reverse, so we end up with `export declare` not `declare export` + modifiers = ensureModifier(ctx, modifiers, ts.SyntaxKind.DeclareKeyword); + return ensureModifier(ctx, modifiers, ts.SyntaxKind.ExportKeyword); +} + +// Make sure replacement node is `export`ed, with the `declare` modifier if it's +// a class, variable or function declaration. +function ensureStatementModifiers( + ctx: ts.TransformationContext, + node: ts.Node +): ts.Node { + if (ts.isClassDeclaration(node)) { + return ctx.factory.updateClassDeclaration( + node, + node.decorators, + ensureExportDeclareModifiers(ctx, node.modifiers), + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } + if (ts.isInterfaceDeclaration(node)) { + return ctx.factory.updateInterfaceDeclaration( + node, + node.decorators, + ensureExportModifier(ctx, node.modifiers), + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } + if (ts.isEnumDeclaration(node)) { + return ctx.factory.updateEnumDeclaration( + node, + node.decorators, + ensureExportDeclareModifiers(ctx, node.modifiers), + node.name, + node.members + ); + } + if (ts.isTypeAliasDeclaration(node)) { + return ctx.factory.updateTypeAliasDeclaration( + node, + node.decorators, + ensureExportModifier(ctx, node.modifiers), + node.name, + node.typeParameters, + node.type + ); + } + if (ts.isVariableStatement(node)) { + return ctx.factory.updateVariableStatement( + node, + ensureExportDeclareModifiers(ctx, node.modifiers), + node.declarationList + ); + } + if (ts.isFunctionDeclaration(node)) { + return ctx.factory.updateFunctionDeclaration( + node, + node.decorators, + ensureExportDeclareModifiers(ctx, node.modifiers), + node.asteriskToken, + node.name, + node.typeParameters, + node.parameters, + node.type, + node.body + ); + } + if (ts.isModuleDeclaration(node)) { + return ctx.factory.updateModuleDeclaration( + node, + node.decorators, + ensureExportDeclareModifiers(ctx, node.modifiers), + node.name, + node.body + ); + } + assert.fail(`Expected statement, got "${printNode(node)}"`); +} diff --git a/types/test/generator/index.spec.ts b/types/test/generator/index.spec.ts index a8260e8b516..aadbc76b51b 100644 --- a/types/test/generator/index.spec.ts +++ b/types/test/generator/index.spec.ts @@ -43,11 +43,12 @@ test("generateDefinitions: only includes referenced types from roots", () => { // type const group = groups.get(1); group.setName("definitions"); - const structures = group.initStructures(8); + const structures = group.initStructures(4); const root1 = structures.get(0); - root1.setName("ServiceWorkerGlobalScope"); - root1.setFullyQualifiedName("workerd::api::ServiceWorkerGlobalScope"); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); { const members = root1.initMembers(7); @@ -103,8 +104,9 @@ test("generateDefinitions: only includes referenced types from roots", () => { initAsNestedStructure(nested); const root2 = structures.get(2); - root2.setName("ExportedHandler"); - root2.setFullyQualifiedName("workerd::api::ExportedHandler"); + root2.setName("Root2"); + root2.setFullyQualifiedName("workerd::api::Root2"); + root2.setTsRoot(true); { const members = root2.initMembers(3); @@ -128,23 +130,8 @@ test("generateDefinitions: only includes referenced types from roots", () => { initAsReferencedType(18, asyncIterator.initReturnType()); initAsReferencedType(19, root2.initExtends()); - // TODO(soon): remove these once we implement JSG_TS_ROOT macro - // (right now roots are hardcoded and we need to be able to find them all) - const root3 = structures.get(3); - root3.setName("DurableObjectNamespace"); - root3.setFullyQualifiedName("workerd::api::DurableObjectNamespace"); - const root4 = structures.get(4); - root4.setName("AnalyticsEngine"); - root4.setFullyQualifiedName("workerd::api::AnalyticsEngine"); - const root5 = structures.get(5); - root5.setName("KvNamespace"); - root5.setFullyQualifiedName("workerd::api::KvNamespace"); - const root6 = structures.get(6); - root6.setName("R2Bucket"); - root6.setFullyQualifiedName("workerd::api::public_beta::R2Bucket"); - // Types referenced by non-roots shouldn't be included - const nonRoot = structures.get(7); + const nonRoot = structures.get(3); nonRoot.setName("NonRoot"); nonRoot.setFullyQualifiedName("workerd::api::NonRoot"); const members = nonRoot.initMembers(1); @@ -158,12 +145,10 @@ test("generateDefinitions: only includes referenced types from roots", () => { const nodes = generateDefinitions(root); assert.strictEqual( printNodeList(nodes), - `// referenced -${referencedInterfaces} + `${referencedInterfaces} export declare abstract class Thing19 { } -// definitions -export interface ServiceWorkerGlobalScope { +export interface Root1 { promise: Promise; structure: Thing1; array: Thing2[]; @@ -175,21 +160,13 @@ export interface ServiceWorkerGlobalScope { export declare abstract class Nested { nestedProp: Thing11; } -export declare class ExportedHandler extends Thing19 { +export declare class Root2 extends Thing19 { constructor(param0: Thing14); method(param0: Thing12): Thing13; Nested: typeof Nested; [Symbol.iterator](param0: Thing15): Thing16; [Symbol.asyncIterator](param0: Thing17): Thing18; } -export interface DurableObjectNamespace { -} -export interface AnalyticsEngine { -} -export interface KvNamespace { -} -export interface R2Bucket { -} ` ); }); @@ -204,12 +181,12 @@ test("generateDefinitions: only generates classes if required", () => { // Generate group containing definitions with each possible class requirement const group = groups.get(1); group.setName("definitions"); - const structures = group.initStructures(6); + const structures = group.initStructures(4); - // TODO(soon): rename these once we implement JSG_TS_ROOT macro const root1 = structures.get(0); - root1.setName("ServiceWorkerGlobalScope"); - root1.setFullyQualifiedName("workerd::api::ServiceWorkerGlobalScope"); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); // Thing0 should be a class as it's a nested type { const members = root1.initMembers(1); @@ -219,8 +196,9 @@ test("generateDefinitions: only generates classes if required", () => { } const root2 = structures.get(1); - root2.setName("ExportedHandler"); - root2.setFullyQualifiedName("workerd::api::ExportedHandler"); + root2.setName("Root2"); + root2.setFullyQualifiedName("workerd::api::Root2"); + root2.setTsRoot(true); { const members = root2.initMembers(1); // ExportedHandler should be a class as it's constructible @@ -228,8 +206,9 @@ test("generateDefinitions: only generates classes if required", () => { } const root3 = structures.get(2); - root3.setName("DurableObjectNamespace"); - root3.setFullyQualifiedName("workerd::api::DurableObjectNamespace"); + root3.setName("Root3"); + root3.setFullyQualifiedName("workerd::api::Root3"); + root3.setTsRoot(true); { const members = root3.initMembers(1); const method = members.get(0).initMethod(); @@ -240,43 +219,29 @@ test("generateDefinitions: only generates classes if required", () => { } const root4 = structures.get(3); - root4.setName("AnalyticsEngine"); - root4.setFullyQualifiedName("workerd::api::AnalyticsEngine"); + root4.setName("Root4"); + root4.setFullyQualifiedName("workerd::api::Root4"); + root4.setTsRoot(true); // Thing1 should be a class as its inherited initAsReferencedType(1, root4.initExtends()); - // TODO(soon): remove these once we implement JSG_TS_ROOT macro - // (right now roots are hardcoded and we need to be able to find them all) - const root5 = structures.get(4); - root5.setName("KvNamespace"); - root5.setFullyQualifiedName("workerd::api::KvNamespace"); - const root6 = structures.get(5); - root6.setName("R2Bucket"); - root6.setFullyQualifiedName("workerd::api::public_beta::R2Bucket"); - const nodes = generateDefinitions(root); assert.strictEqual( printNodeList(nodes), - `// referenced -export declare abstract class Thing0 { + `export declare abstract class Thing0 { } export declare abstract class Thing1 { } -// definitions -export interface ServiceWorkerGlobalScope { +export interface Root1 { Thing0: typeof Thing0; } -export declare class ExportedHandler { +export declare class Root2 { constructor(); } -export declare abstract class DurableObjectNamespace { +export declare abstract class Root3 { static method(): void; } -export interface AnalyticsEngine extends Thing1 { -} -export interface KvNamespace { -} -export interface R2Bucket { +export interface Root4 extends Thing1 { } ` ); diff --git a/types/test/generator/structure.spec.ts b/types/test/generator/structure.spec.ts index 9ab333d1205..7cf33dc8a86 100644 --- a/types/test/generator/structure.spec.ts +++ b/types/test/generator/structure.spec.ts @@ -189,7 +189,7 @@ test("createStructureNode: constant members", () => { assert.strictEqual( printNode(createStructureNode(structure, true)), `export declare abstract class Constants { - static readonly THING: 42; + static readonly THING: number; }` ); }); diff --git a/types/test/index.spec.ts b/types/test/index.spec.ts index 8da9f9653b8..087df93bf3d 100644 --- a/types/test/index.spec.ts +++ b/types/test/index.spec.ts @@ -2,7 +2,7 @@ import assert from "assert"; import fs from "fs/promises"; import { test } from "node:test"; import path from "path"; -import { StructureGroups } from "@workerd/jsg/rtti.capnp.js"; +import { BuiltinType_Type, StructureGroups } from "@workerd/jsg/rtti.capnp.js"; import { Message } from "capnp-ts"; import { main } from "../src"; @@ -12,14 +12,52 @@ test("main: generates types", async () => { const groups = root.initGroups(1); const group = groups.get(0); group.setName("definitions"); - const structures = group.initStructures(8); + const structures = group.initStructures(5); - // TODO(soon): rename/remove these once we implement JSG_TS_ROOT macro - const root1 = structures.get(0); - root1.setName("ServiceWorkerGlobalScope"); - root1.setFullyQualifiedName("workerd::api::ServiceWorkerGlobalScope"); + const eventTarget = structures.get(0); + eventTarget.setName("EventTarget"); + eventTarget.setFullyQualifiedName("workerd::api::EventTarget"); { - const members = root1.initMembers(2); + const members = eventTarget.initMembers(2); + members.get(0).initConstructor(); + const method = members.get(1).initMethod(); + method.setName("addEventListener"); + { + const args = method.initArgs(2); + args.get(0).initString().setName("kj::String"); + args.get(1).initBuiltin().setType(BuiltinType_Type.V8FUNCTION); + } + method.initReturnType().setVoidt(); + } + eventTarget.setTsOverride(` = Record> { + addEventListener(type: Type, handler: (event: EventMap[Type]) => void): void; + }`); + + const workerGlobalScope = structures.get(1); + workerGlobalScope.setName("WorkerGlobalScope"); + workerGlobalScope.setFullyQualifiedName("workerd::api::WorkerGlobalScope"); + let extendsStructure = workerGlobalScope.initExtends().initStructure(); + extendsStructure.setName("EventTarget"); + extendsStructure.setFullyQualifiedName("workerd::api::EventTarget"); + workerGlobalScope.setTsDefine(`type WorkerGlobalScopeEventMap = { + fetch: Event; + scheduled: Event; + }`); + workerGlobalScope.setTsOverride( + "extends EventTarget" + ); + + const serviceWorkerGlobalScope = structures.get(2); + serviceWorkerGlobalScope.setName("ServiceWorkerGlobalScope"); + serviceWorkerGlobalScope.setFullyQualifiedName( + "workerd::api::ServiceWorkerGlobalScope" + ); + extendsStructure = serviceWorkerGlobalScope.initExtends().initStructure(); + extendsStructure.setName("WorkerGlobalScope"); + extendsStructure.setFullyQualifiedName("workerd::api::WorkerGlobalScope"); + serviceWorkerGlobalScope.setTsRoot(true); + { + const members = serviceWorkerGlobalScope.initMembers(2); // Test that global extraction is performed after iterator processing const method = members.get(0).initMethod(); @@ -37,23 +75,7 @@ test("main: generates types", async () => { prop.initType().initPromise().initValue().initNumber().setName("int"); } - const root2 = structures.get(1); - root2.setName("ExportedHandler"); - root2.setFullyQualifiedName("workerd::api::ExportedHandler"); - const root3 = structures.get(2); - root3.setName("DurableObjectNamespace"); - root3.setFullyQualifiedName("workerd::api::DurableObjectNamespace"); - const root4 = structures.get(3); - root4.setName("AnalyticsEngine"); - root4.setFullyQualifiedName("workerd::api::AnalyticsEngine"); - const root5 = structures.get(4); - root5.setName("KvNamespace"); - root5.setFullyQualifiedName("workerd::api::KvNamespace"); - const root6 = structures.get(5); - root6.setName("R2Bucket"); - root6.setFullyQualifiedName("workerd::api::public_beta::R2Bucket"); - - const iterator = structures.get(6); + const iterator = structures.get(3); iterator.setName("ThingIterator"); iterator.setFullyQualifiedName("workerd::api::ThingIterator"); iterator.initExtends().initIntrinsic().setName("v8::kIteratorPrototype"); @@ -68,7 +90,7 @@ test("main: generates types", async () => { const iteratorMethod = iterator.initIterator(); iteratorMethod.initReturnType().setUnknown(); } - const iteratorNext = structures.get(7); + const iteratorNext = structures.get(4); iteratorNext.setName("ThingIteratorNext"); iteratorNext.setFullyQualifiedName("workerd::api::ThingIteratorNext"); { @@ -97,23 +119,23 @@ test("main: generates types", async () => { output, `/* eslint-disable */ // noinspection JSUnusedGlobalSymbols -// definitions -export interface ServiceWorkerGlobalScope { +export declare class EventTarget = Record> { + constructor(); + addEventListener(type: Type, handler: (event: EventMap[Type]) => void): void; +} +export type WorkerGlobalScopeEventMap = { + fetch: Event; + scheduled: Event; +}; +export declare abstract class WorkerGlobalScope extends EventTarget { +} +export interface ServiceWorkerGlobalScope extends WorkerGlobalScope { things(param0: boolean): IterableIterator; get prop(): Promise; } +export declare function addEventListener(type: Type, handler: (event: WorkerGlobalScopeEventMap[Type]) => void): void; export declare function things(param0: boolean): IterableIterator; export declare const prop: Promise; -export interface ExportedHandler { -} -export interface DurableObjectNamespace { -} -export interface AnalyticsEngine { -} -export interface KvNamespace { -} -export interface R2Bucket { -} ` ); @@ -124,18 +146,29 @@ export interface R2Bucket { output, `/* eslint-disable */ // noinspection JSUnusedGlobalSymbols -// definitions -export interface ServiceWorkerGlobalScope { +export declare class EventTarget< + EventMap extends Record = Record +> { + constructor(); + addEventListener( + type: Type, + handler: (event: EventMap[Type]) => void + ): void; +} +export type WorkerGlobalScopeEventMap = { + fetch: Event; + scheduled: Event; +}; +export declare abstract class WorkerGlobalScope extends EventTarget {} +export interface ServiceWorkerGlobalScope extends WorkerGlobalScope { things(param0: boolean): IterableIterator; get prop(): Promise; } +export declare function addEventListener< + Type extends keyof WorkerGlobalScopeEventMap +>(type: Type, handler: (event: WorkerGlobalScopeEventMap[Type]) => void): void; export declare function things(param0: boolean): IterableIterator; export declare const prop: Promise; -export interface ExportedHandler {} -export interface DurableObjectNamespace {} -export interface AnalyticsEngine {} -export interface KvNamespace {} -export interface R2Bucket {} ` ); }); diff --git a/types/test/transforms/globals.spec.ts b/types/test/transforms/globals.spec.ts index 55e53e69f85..6866c3f7284 100644 --- a/types/test/transforms/globals.spec.ts +++ b/types/test/transforms/globals.spec.ts @@ -1,19 +1,23 @@ import assert from "assert"; import { test } from "node:test"; +import path from "path"; import ts from "typescript"; import { printer } from "../../src/print"; import { createMemoryProgram } from "../../src/program"; import { createGlobalScopeTransformer } from "../../src/transforms"; test("createGlobalScopeTransformer: extracts global scope", () => { - // TODO(soon): make EventTarget generic once overrides implemented - const source = `export declare class EventTarget { + const source = `export type WorkerGlobalScopeEventMap = { + fetch: Event; + scheduled: Event; +}; +export declare class EventTarget = Record> { constructor(); - addEventListener(type: string, handler: (event: Event) => void): void; // MethodDeclaration - removeEventListener(type: string, handler: (event: Event) => void): void; // MethodDeclaration - dispatchEvent(event: Event): void; // MethodDeclaration + addEventListener(type: Type, handler: (event: EventMap[Type]) => void): void; // MethodDeclaration + removeEventListener(type: Type, handler: (event: EventMap[Type]) => void): void; // MethodDeclaration + dispatchEvent(event: EventMap[keyof EventMap]): void; // MethodDeclaration } -export declare class WorkerGlobalScope extends EventTarget { +export declare class WorkerGlobalScope extends EventTarget { thing: string; // PropertyDeclaration static readonly CONSTANT: 42; // PropertyDeclaration get property(): number; // GetAccessorDeclaration @@ -32,7 +36,10 @@ export interface ServiceWorkerGlobalScope extends WorkerGlobalScope { get console(): Console; // GetAccessorDeclaration } `; - const [program, sourcePath] = createMemoryProgram(source); + + const sourcePath = path.resolve(__dirname, "source.ts"); + const sources = new Map([[sourcePath, source]]); + const program = createMemoryProgram(sources); const checker = program.getTypeChecker(); const sourceFile = program.getSourceFile(sourcePath); assert(sourceFile !== undefined); @@ -47,9 +54,9 @@ export interface ServiceWorkerGlobalScope extends WorkerGlobalScope { output, // Extracted global nodes inserted after ServiceWorkerGlobalScope source + - `export declare function addEventListener(type: string, handler: (event: Event) => void): void; -export declare function removeEventListener(type: string, handler: (event: Event) => void): void; -export declare function dispatchEvent(event: Event): void; + `export declare function addEventListener(type: Type, handler: (event: WorkerGlobalScopeEventMap[Type]) => void): void; +export declare function removeEventListener(type: Type, handler: (event: WorkerGlobalScopeEventMap[Type]) => void): void; +export declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): void; export declare const thing: string; export declare const CONSTANT: 42; export declare const property: number; @@ -59,3 +66,34 @@ export declare const console: Console; ` ); }); + +test("createGlobalScopeTransformer: inlining type parameters in heritage", () => { + const source = `export declare class A { + thing: T; +} +export declare class B extends A { +} +export declare class ServiceWorkerGlobalScope extends B { +} +`; + + const sourcePath = path.resolve(__dirname, "source.ts"); + const sources = new Map([[sourcePath, source]]); + const program = createMemoryProgram(sources); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(sourcePath); + assert(sourceFile !== undefined); + + const result = ts.transform(sourceFile, [ + createGlobalScopeTransformer(checker), + ]); + assert.strictEqual(result.transformed.length, 1); + + const output = printer.printFile(result.transformed[0]); + assert.strictEqual( + output, + source + + `export declare const thing: string; +` + ); +}); diff --git a/types/test/transforms/iterators.spec.ts b/types/test/transforms/iterators.spec.ts index 7a3c4d0e288..3502b85215a 100644 --- a/types/test/transforms/iterators.spec.ts +++ b/types/test/transforms/iterators.spec.ts @@ -1,5 +1,6 @@ import assert from "assert"; import { test } from "node:test"; +import path from "path"; import ts from "typescript"; import { printer } from "../../src/print"; import { createMemoryProgram } from "../../src/program"; @@ -32,7 +33,9 @@ export interface AsyncThingIteratorNext { value?: number; } `; - const [program, sourcePath] = createMemoryProgram(source); + const sourcePath = path.resolve(__dirname, "source.ts"); + const sources = new Map([[sourcePath, source]]); + const program = createMemoryProgram(sources); const checker = program.getTypeChecker(); const sourceFile = program.getSourceFile(sourcePath); assert(sourceFile !== undefined); diff --git a/types/test/transforms/overrides/index.spec.ts b/types/test/transforms/overrides/index.spec.ts new file mode 100644 index 00000000000..5cacc585ee2 --- /dev/null +++ b/types/test/transforms/overrides/index.spec.ts @@ -0,0 +1,484 @@ +import assert from "assert"; +import { test } from "node:test"; +import path from "path"; +import { + Member_Nested, + StructureGroups, + Type, +} from "@workerd/jsg/rtti.capnp.js"; +import { Message } from "capnp-ts"; +import ts from "typescript"; +import { generateDefinitions } from "../../../src/generator"; +import { printNodeList, printer } from "../../../src/print"; +import { createMemoryProgram } from "../../../src/program"; +import { + compileOverridesDefines, + createOverrideDefineTransformer, +} from "../../../src/transforms"; + +function printDefinitionsWithOverrides(root: StructureGroups): string { + const nodes = generateDefinitions(root); + + const [sources, replacements] = compileOverridesDefines(root); + const sourcePath = path.resolve(__dirname, "source.ts"); + const source = printNodeList(nodes); + sources.set(sourcePath, source); + + const program = createMemoryProgram(sources); + const sourceFile = program.getSourceFile(sourcePath); + assert(sourceFile !== undefined); + + const result = ts.transform(sourceFile, [ + createOverrideDefineTransformer(program, replacements), + ]); + assert.strictEqual(result.transformed.length, 1); + + return printer.printFile(result.transformed[0]); +} + +test("createOverrideDefineTransformer: applies type renames", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(2); + + const thing = structures.get(0); + thing.setName("Thing"); + thing.setFullyQualifiedName("workerd::api::Thing"); + thing.setTsOverride("RenamedThing"); + function referenceThing(type: Type | Member_Nested) { + const structureType = type.initStructure(); + structureType.setName("Thing"); + structureType.setFullyQualifiedName("workerd::api::Thing"); + } + + // Create type root that references Thing in different ways to test renaming + const root1 = structures.get(1); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + // Make sure references to original names in overrides get renamed too + root1.setTsOverride("{ newProp: Thing; }"); + { + const members = root1.initMembers(3); + + const prop = members.get(0).initProperty(); + prop.setName("prop"); + referenceThing(prop.initType()); + + const method = members.get(1).initMethod(); + method.setName("method"); + referenceThing(method.initArgs(1).get(0)); + referenceThing(method.initReturnType()); + + const nested = members.get(2).initNested(); + nested.setName("Thing"); // Should keep original name + referenceThing(nested); + } + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export declare abstract class RenamedThing { +} +export interface Root1 { + prop: RenamedThing; + method(param0: RenamedThing): RenamedThing; + Thing: typeof RenamedThing; + newProp: RenamedThing; +} +` + ); +}); + +test("createOverrideDefineTransformer: applies property overrides", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(1); + + const root1 = structures.get(0); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + { + const members = root1.initMembers(6); + + // Readonly instance property, overridden to be mutable and optional + const prop1 = members.get(0).initProperty(); + prop1.setName("prop1"); + prop1.initType().initString().setName("kj::String"); + prop1.setReadonly(true); + + // Mutable instance property, overridden to be readonly and required + const prop2 = members.get(1).initProperty(); + prop2.setName("prop2"); + prop2.initType().initMaybe().initValue().setBoolt(); + + // Readonly prototype property, overridden to be mutable + const prop3 = members.get(2).initProperty(); + prop3.setName("prop3"); + prop3.initType().initArray().initElement().setBoolt(); + prop3.setReadonly(true); + prop3.setPrototype(true); + + // Mutable prototype property, overridden to be readonly + const prop4 = members.get(3).initProperty(); + prop4.setName("prop4"); + prop4.initType().initNumber().setName("int"); + prop4.setPrototype(true); + + // Deleted property + const prop5 = members.get(4).initProperty(); + prop5.setName("prop5"); + prop5.initType().setBoolt(); + + // Untouched property + const prop6 = members.get(5).initProperty(); + prop6.setName("prop6"); + prop6.initType().initPromise().initValue().setVoidt(); + } + root1.setTsOverride(`{ + prop1?: "thing"; + readonly prop2: true; + get prop3(): false; + set prop3(value: false); + get prop4(): 1 | 2 | 3; + prop5: never; + }`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export interface Root1 { + prop1?: "thing"; + readonly prop2: true; + get prop3(): false; + set prop3(value: false); + get prop4(): 1 | 2 | 3; + prop6: Promise; +} +` + ); +}); + +test("createOverrideDefineTransformer: applies method overrides", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(1); + + const root1 = structures.get(0); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + { + const members = root1.initMembers(7); + + // Static and instance methods with the same names + const method1 = members.get(0).initMethod(); + method1.setName("one"); + method1.initReturnType().initNumber().setName("int"); + const staticMethod1 = members.get(1).initMethod(); + staticMethod1.setName("one"); + staticMethod1.initReturnType().initNumber().setName("int"); + staticMethod1.setStatic(true); + const method2 = members.get(2).initMethod(); + method2.setName("two"); + method2.initReturnType().initNumber().setName("int"); + const staticMethod2 = members.get(3).initMethod(); + staticMethod2.setName("two"); + staticMethod2.initReturnType().initNumber().setName("int"); + staticMethod2.setStatic(true); + + // Method with multiple overloads + const methodGet = members.get(4).initMethod(); + methodGet.setName("get"); + { + const args = methodGet.initArgs(2); + args.get(0).initString().setName("kj::String"); + const variants = args.get(1).initOneOf().initVariants(2); + variants.get(0).initString().setName("kj::String"); + variants.get(1).setUnknown(); + } + const methodGetReturn = methodGet.initReturnType().initMaybe(); + methodGetReturn.setName("kj::Maybe"); + methodGetReturn.initValue().setUnknown(); + + // Deleted method + const methodDeleteAll = members.get(5).initMethod(); + methodDeleteAll.setName("deleteAll"); + methodDeleteAll.initReturnType().setVoidt(); + + // Untouched method + const methodThing = members.get(6).initMethod(); + methodThing.setName("thing"); + methodThing.initArgs(1).get(0).setBoolt(); + methodThing.initReturnType().setBoolt(); + } + // These overrides test: + // - Overriding a static method with an instance method of the same name + // - Overriding an instance method with a static method of the same name + // - Split overloads, these should be grouped + // - Deleted method + root1.setTsOverride(`{ + static one(): 1; + two(): 2; + + get(key: string, type: "text"): Promise; + get(key: string, type: "arrayBuffer"): Promise; + + deleteAll: never; + + get(key: string, type: "json"): Promise; + }`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export declare abstract class Root1 { + one(): number; + static one(): 1; + two(): 2; + static two(): number; + get(key: string, type: "text"): Promise; + get(key: string, type: "arrayBuffer"): Promise; + get(key: string, type: "json"): Promise; + thing(param0: boolean): boolean; +} +` + ); +}); + +test("createOverrideDefineTransformer: applies type parameter overrides", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(2); + + const struct = structures.get(0); + struct.setName("Struct"); + struct.setFullyQualifiedName("workerd::api::Struct"); + { + const members = struct.initMembers(1); + const prop = members.get(0).initProperty(); + prop.setName("type"); + prop.initType().setUnknown(); + } + struct.setTsOverride(`RenamedStruct { + type: Type; + }`); + + const root1 = structures.get(1); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + { + const members = root1.initMembers(2); + + const methodGet = members.get(0).initMethod(); + methodGet.setName("get"); + const returnStruct = methodGet.initReturnType().initStructure(); + returnStruct.setName("Struct"); + returnStruct.setFullyQualifiedName("workerd::api::Struct"); + + const methodRead = members.get(1).initMethod(); + methodRead.setName("read"); + methodRead.initReturnType().initPromise().initValue().setUnknown(); + } + root1.setTsOverride(` { + read(): Promise; + }`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export interface RenamedStruct { + type: Type; +} +export interface Root1 { + get(): RenamedStruct; + read(): Promise; +} +` + ); +}); + +test("createOverrideDefineTransformer: applies heritage overrides", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(4); + + const superclass = structures.get(0); + superclass.setName(`Superclass`); + superclass.setFullyQualifiedName(`workerd::api::Superclass`); + superclass.setTsOverride(""); + + const root1 = structures.get(1); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + const root1Extends = root1.initExtends().initStructure(); + root1Extends.setName("Superclass"); + root1Extends.setFullyQualifiedName("workerd::api::Superclass"); + root1.setTsRoot(true); + root1.setTsOverride( + `extends Superclass` + ); + + const root2 = structures.get(2); + root2.setName("Root2"); + root2.setFullyQualifiedName("workerd::api::Root2"); + const root2Extends = root1.initExtends().initStructure(); + root2Extends.setName("Superclass"); + root2Extends.setFullyQualifiedName("workerd::api::Superclass"); + root2.setTsRoot(true); + root2.setTsOverride("Root2 implements Superclass"); + + const root3 = structures.get(3); + root3.setName("Root3"); + root3.setFullyQualifiedName("workerd::api::Root3"); + const root3Extends = root1.initExtends().initStructure(); + root3Extends.setName("Superclass"); + root3Extends.setFullyQualifiedName("workerd::api::Superclass"); + root3.setTsRoot(true); + { + const members = root3.initMembers(1); + const prop = members.get(0).initProperty(); + prop.setName("prop"); + prop.initType().initNumber().setName("int"); + } + root3.setTsOverride(`extends Superclass { + prop: 1 | 2 | 3; + }`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export declare abstract class Superclass { +} +export interface Root1 extends Superclass { +} +export interface Root2 implements Superclass { +} +export interface Root3 extends Superclass { + prop: 1 | 2 | 3; +} +` + ); +}); + +test("createOverrideDefineTransformer: applies full type replacements", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(4); + + const root1 = structures.get(0); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + root1.setTsOverride(`const Root1 = { + new (): { 0: Root2; 1: Root3; }; + }`); + + const root2 = structures.get(1); + root2.setName("Root2"); + root2.setFullyQualifiedName("workerd::api::Root2"); + root2.setTsRoot(true); + root2.setTsOverride(`enum Root2 { ONE, TWO, THREE; }`); + + const root3 = structures.get(2); + root3.setName("Root3"); + root3.setFullyQualifiedName("workerd::api::Root3"); + root3.setTsRoot(true); + // Check renames still applied with full-type replacements + root3.setTsOverride( + `type RenamedRoot3 = { done: false; value: T; } | { done: true; value: undefined; }` + ); + + const root4 = structures.get(3); + root4.setName("Root4"); + root4.setFullyQualifiedName("workerd::api::Root4"); + root4.setTsRoot(true); + root4.setTsOverride(`type Root4 = never`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export declare const Root1 = { + new(): { + 0: Root2; + 1: RenamedRoot3; + }; +}; +export declare enum Root2 { + ONE, + TWO, + THREE +} +export type RenamedRoot3 = { + done: false; + value: T; +} | { + done: true; + value: undefined; +}; +` + ); +}); + +test("createOverrideDefineTransformer: applies overrides with literals", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(1); + + const root1 = structures.get(0); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + root1.setTsOverride(`{ + literalString: "hello"; + literalNumber: 42; + literalArray: [a: "a", b: 2]; + literalObject: { a: "a"; b: 2; }; + literalTemplate: \`\${string}-\${number}\`; + }`); + + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export interface Root1 { + literalString: "hello"; + literalNumber: 42; + literalArray: [ + a: "a", + b: 2 + ]; + literalObject: { + a: "a"; + b: 2; + }; + literalTemplate: \`\${string}-\${number}\`; +} +` + ); +}); + +test("createOverrideDefineTransformer: inserts extra defines", () => { + const root = new Message().initRoot(StructureGroups); + const group = root.initGroups(1).get(0); + const structures = group.initStructures(2); + + const root1 = structures.get(0); + root1.setName("Root1"); + root1.setFullyQualifiedName("workerd::api::Root1"); + root1.setTsRoot(true); + + const root2 = structures.get(1); + root2.setName("Root2"); + root2.setFullyQualifiedName("workerd::api::Root2"); + root2.setTsRoot(true); + root2.setTsDefine("interface Root2Extra { prop: Type }"); + root2.setTsOverride("RenamedRoot2"); + + // Check defines inserted before structure + assert.strictEqual( + printDefinitionsWithOverrides(root), + `export interface Root1 { +} +export interface Root2Extra { + prop: Type; +} +export interface RenamedRoot2 { +} +` + ); +});