diff --git a/.husky/pre-commit b/.husky/pre-commit index 1b6a53c7..daf0da48 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -9,12 +9,12 @@ fi echo '--- checking for consistent dependencies across packages' pnpm lint-shared-versions -echo '--- run linting --- ' -pnpm lint - echo '--- run prettier ---' pnpm prettier-check +echo '--- run linting --- ' +pnpm lint + echo '--- run typescript check ---' pnpm check-types diff --git a/src/packages/http/index.ts b/src/packages/http/index.ts index 0c8a9dff..dc7f262c 100644 --- a/src/packages/http/index.ts +++ b/src/packages/http/index.ts @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: con terra GmbH and contributors // SPDX-License-Identifier: Apache-2.0 +import { DeclaredService } from "@open-pioneer/runtime"; /** * Central service for sending HTTP requests. * * Use the interface `"http.HttpService"` to obtain an instance of this service. */ -export interface HttpService { +export interface HttpService extends DeclaredService<"http.HttpService"> { /** * Requests the given `resource` via HTTP and returns the response. * @@ -21,12 +22,10 @@ export interface HttpService { fetch(resource: RequestInfo | URL, init?: RequestInit): Promise; } +// TODO: Remove block with next major import "@open-pioneer/runtime"; declare module "@open-pioneer/runtime" { interface ServiceRegistry { "http.HttpService": HttpService; } } - -// Get rid of empty chunk warning -export default undefined; diff --git a/src/packages/integration/api.ts b/src/packages/integration/api.ts index 4d5717ac..72ea2fae 100644 --- a/src/packages/integration/api.ts +++ b/src/packages/integration/api.ts @@ -1,13 +1,20 @@ // SPDX-FileCopyrightText: con terra GmbH and contributors // SPDX-License-Identifier: Apache-2.0 -import { type ApiExtension, type ApiMethods, type ApiMethod } from "@open-pioneer/runtime"; +import { + type ApiExtension, + type ApiMethods, + type ApiMethod, + DeclaredService +} from "@open-pioneer/runtime"; + +export { ApiExtension, ApiMethod, ApiMethods }; // re-export for consistency /** * Emits events to users of the current web component. * * Use the interface `"integration.ExternalEventService"` to obtain an instance of this service. */ -export interface ExternalEventService { +export interface ExternalEventService extends DeclaredService<"integration.ExternalEventService"> { /** * Emits an event to the host site as a [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent). * @@ -46,8 +53,7 @@ export interface ExternalEventService { emitEvent(event: Event): void; } -export { ApiExtension, ApiMethod, ApiMethods }; // re-export for consistency - +// TODO: Remove block with next major import "@open-pioneer/runtime"; declare module "@open-pioneer/runtime" { interface ServiceRegistry { diff --git a/src/packages/runtime/DeclaredService.test.ts b/src/packages/runtime/DeclaredService.test.ts new file mode 100644 index 00000000..8df71a02 --- /dev/null +++ b/src/packages/runtime/DeclaredService.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: con terra GmbH and contributors +// SPDX-License-Identifier: Apache-2.0 +/* eslint-disable unused-imports/no-unused-vars */ +import { DeclaredService, InterfaceNameForServiceType } from "./DeclaredService"; + +// Tests are on type level only +it("dummy test to allow a file without any real tests", () => undefined); + +/** + * Returns type `true` if types A and B are equal (type false otherwise). + * See here: https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650 + */ +// prettier-ignore +type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false; + +/** + * Returns type `true` if T is any kind of string, `false` otherwise. + */ +type IsString = T extends string ? true : false; + +// Expect all strings are allowed when service type is unknown +{ + type IFace = InterfaceNameForServiceType; + const isString: Equal = true; +} + +// Expect only the declared interface name is allowed when an explicit service is provided +{ + interface MyService extends DeclaredService<"my.service"> { + foo(): void; + } + + type IFace = InterfaceNameForServiceType; + const isConstant: Equal = true; +} + +// Expect an error is returned when an explicit type is used that does not extend DeclaredService +{ + interface MyService { + foo(): void; + } + + type IFace = InterfaceNameForServiceType; + const isString: IsString = false; +} diff --git a/src/packages/runtime/DeclaredService.ts b/src/packages/runtime/DeclaredService.ts new file mode 100644 index 00000000..8be8487d --- /dev/null +++ b/src/packages/runtime/DeclaredService.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: con terra GmbH and contributors +// SPDX-License-Identifier: Apache-2.0 +declare const INTERNAL_ASSOCIATED_SERVICE_METADATA: unique symbol; +declare const ERROR: unique symbol; + +/** + * Base interface for services that are associated with a well known interface name. + * + * By using this base interface, you can ensure that users of your interface use the correct interface name. + * + * @example + * ```ts + * // MyLogger should be referenced via "my-package.Logger" + * export interface MyLogger extends DeclaredService<"my-package.Logger"> { + * log(message: string): void; + * } + * ``` + * + * > Note: TypeScript may list the `INTERNAL_ASSOCIATED_SERVICE_METADATA` property + * > when generating the implementation for an interface extending this type. + * > You can simply remove the offending line; it is not required (and not possible) + * > to implement that attribute - it only exists for the compiler. + */ +export interface DeclaredService { + /** + * Internal type-level service metadata. + * + * Note: there is no need to implement this symbol attribute. + * It is optional and only exists for the compiler, never at runtime. + * + * @internal + */ + [INTERNAL_ASSOCIATED_SERVICE_METADATA]?: ServiceMetadata; +} + +/** + * Given a type implementing {@link DeclaredService}, this type will produce the interface name associated with the service type. + */ +export type AssociatedInterfaceName> = T extends DeclaredService< + infer InterfaceName +> + ? InterfaceName + : never; + +/** + * This helper type produces the expected `interfaceName` (a string parameter) for the given service type. + * + * 1. If `ServiceType` is `unknown`, it will produce `string` to allow arbitrary parameters. + * 2. If `ServiceType` implements {@link DeclaredService}, it will enforce the associated interface name. + * 3. Otherwise, a compile time error is generated. + */ +export type InterfaceNameForServiceType = unknown extends ServiceType + ? string + : ServiceType extends DeclaredService + ? AssociatedInterfaceName + : { + [ERROR]: "TypeScript integration was not set up properly for this service. Make sure the service's TypeScript interface extends 'DeclaredService'."; + }; + +/** + * @internal + */ +interface ServiceMetadata { + interfaceName: InterfaceName; +} diff --git a/src/packages/runtime/ServiceRegistry.ts b/src/packages/runtime/ServiceRegistry.ts index 5f3c1c9b..115cb329 100644 --- a/src/packages/runtime/ServiceRegistry.ts +++ b/src/packages/runtime/ServiceRegistry.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: con terra GmbH and contributors // SPDX-License-Identifier: Apache-2.0 - +// eslint-disable-next-line unused-imports/no-unused-imports +import { DeclaredService } from "./DeclaredService"; /** * Maps a registered interface name to a service type. * The interface can be reopened by client packages to add additional registrations. @@ -16,16 +17,26 @@ * } * } * ``` + * + * @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead. + * */ +// TODO: Remove with next major // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ServiceRegistry {} /** * A well known interface name registered with the {@link ServiceRegistry}. + * + * @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead. */ +// TODO: Remove with next major export type InterfaceName = keyof ServiceRegistry; /** * Returns the registered service type for the given interface name. + * + * @deprecated The global service registry is deprecated. Use {@link DeclaredService} in your service interface instead. */ +// TODO: Remove with next major export type ServiceType = ServiceRegistry[I]; diff --git a/src/packages/runtime/api.ts b/src/packages/runtime/api.ts index 53dc503b..f8890188 100644 --- a/src/packages/runtime/api.ts +++ b/src/packages/runtime/api.ts @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: con terra GmbH and contributors // SPDX-License-Identifier: Apache-2.0 +import { DeclaredService } from "./DeclaredService"; /* eslint-disable @typescript-eslint/no-explicit-any */ export type ApiMethod = (...args: any[]) => any; @@ -23,7 +24,7 @@ export interface ApiExtension { * A service provided by the system. * Used by the runtime to assemble the public facing API. */ -export interface ApiService { +export interface ApiService extends DeclaredService<"runtime.ApiService"> { /** * Called by the runtime to gather methods that should be available from the web component's API. */ @@ -33,7 +34,7 @@ export interface ApiService { /** * A service provided by the system, useful for accessing values that are global to the application. */ -export interface ApplicationContext { +export interface ApplicationContext extends DeclaredService<"runtime.ApplicationContext"> { /** * The web component's host element. * This dom node can be accessed by the host site. @@ -73,7 +74,8 @@ export interface ApplicationContext { * **Experimental**. This interface is not affected by semver guarantees. * It may change (or be removed) in a future minor release. */ -export interface ApplicationLifecycleListener { +export interface ApplicationLifecycleListener + extends DeclaredService<"runtime.ApplicationLifecycleListener"> { /** * Called after all services required by the application have been started. */ @@ -85,6 +87,7 @@ export interface ApplicationLifecycleListener { beforeApplicationStop?(): void; } +// TODO: Remove block with next major declare module "./ServiceRegistry" { interface ServiceRegistry { "runtime.ApiService": ApiService; diff --git a/src/packages/runtime/index.ts b/src/packages/runtime/index.ts index 7326b9aa..5088bf2f 100644 --- a/src/packages/runtime/index.ts +++ b/src/packages/runtime/index.ts @@ -15,3 +15,8 @@ export { export * from "./Service"; export * from "./ServiceRegistry"; export * from "./PropertiesRegistry"; +export { + type DeclaredService, + type AssociatedInterfaceName, + type InterfaceNameForServiceType +} from "./DeclaredService"; diff --git a/src/samples/api-sample/api-app/DemoUI.tsx b/src/samples/api-sample/api-app/DemoUI.tsx index 89532c70..b38b199d 100644 --- a/src/samples/api-sample/api-app/DemoUI.tsx +++ b/src/samples/api-sample/api-app/DemoUI.tsx @@ -4,9 +4,10 @@ import { useService } from "open-pioneer:react-hooks"; import { TextService } from "./TextService"; import { Button, Container, VStack, Text, Heading } from "@open-pioneer/chakra-integration"; import { useEffect, useState } from "react"; +import { ExternalEventService } from "@open-pioneer/integration"; export function DemoUI() { - const eventService = useService("integration.ExternalEventService"); + const eventService = useService("integration.ExternalEventService"); const emitEvent = () => { eventService.emitEvent("my-custom-event", { data: "my-event-data" diff --git a/src/samples/extension-sample/extension-app/ActionsServiceImpl.ts b/src/samples/extension-sample/extension-app/ActionsServiceImpl.ts index e766aab7..507309d4 100644 --- a/src/samples/extension-sample/extension-app/ActionsServiceImpl.ts +++ b/src/samples/extension-sample/extension-app/ActionsServiceImpl.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: con terra GmbH and contributors // SPDX-License-Identifier: Apache-2.0 -import { Service, ServiceOptions, ServiceType } from "@open-pioneer/runtime"; -import { Action, ActionService } from "./api"; +import { Service, ServiceOptions } from "@open-pioneer/runtime"; +import { Action, ActionProvider, ActionService } from "./api"; interface References { - providers: ServiceType<"extension-app.ActionProvider">[]; + providers: ActionProvider[]; } export class ActionServiceImpl implements Service { diff --git a/src/samples/extension-sample/extension-app/ActionsUI.tsx b/src/samples/extension-sample/extension-app/ActionsUI.tsx index 25ed017c..62496cc6 100644 --- a/src/samples/extension-sample/extension-app/ActionsUI.tsx +++ b/src/samples/extension-sample/extension-app/ActionsUI.tsx @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { Button, Container, Heading, Text, VStack } from "@open-pioneer/chakra-integration"; import { useService } from "open-pioneer:react-hooks"; +import { ActionService } from "./api"; export function ActionsUI() { - const service = useService("extension-app.ActionService"); + const service = useService("extension-app.ActionService"); const buttons = service.getActionInfo().map(({ id, text }) => (