diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 02ac242d..1850b066 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -12,3 +12,7 @@ runs: run: | npm run test:unit shell: bash + - name: Run type tests + run: | + npm run test:types + shell: bash diff --git a/doc/7/getting-started/react/index.md b/doc/7/getting-started/react/index.md new file mode 100644 index 00000000..2bf486b7 --- /dev/null +++ b/doc/7/getting-started/react/index.md @@ -0,0 +1,7 @@ +--- +code: false +type: branch +title: React +description: Get started with the Javascript SDK and React +order: 200 +--- diff --git a/doc/7/getting-started/react/standalone/index.md b/doc/7/getting-started/react/standalone/index.md new file mode 100644 index 00000000..fc07266b --- /dev/null +++ b/doc/7/getting-started/react/standalone/index.md @@ -0,0 +1,106 @@ +--- +code: false +type: page +title: Standalone +description: Getting started with Kuzzle and React +order: 100 +--- + +# Getting Started with Kuzzle and React + +This tutorial explains how to use **Kuzzle** with the **Javascript SDK 7** and **React**. + +You are going to write a realtime chat: messages are stored as **documents** in Kuzzle, and every client is kept up to date through [document notifications](/sdk/js/7/essentials/realtime-notifications#document-messages). + +To follow this tutorial, you must have a Kuzzle Server up and running. Follow these instructions if this is not already the case: [Running Kuzzle](/core/2/guides/getting-started/run-kuzzle). + +:::info +Having trouble? Get in touch with us on [Discord](http://join.discord.kuzzle.io)! +::: + +## Requirements + +- **Node.js** >= 20 ([download page](https://nodejs.org/en/download/)) +- a **running Kuzzle V2 stack** ([instructions here](/core/2/guides/getting-started/run-kuzzle)) + +## Prepare your environment + +Create a React application with [Vite](https://vite.dev/) and install the Javascript SDK: + +```bash +npm create vite@latest kuzzle-playground -- --template react +cd kuzzle-playground +npm install +npm install kuzzle-sdk@7 +``` + +You can now empty `src/App.jsx` and `src/App.css`: we are going to rewrite them. + +## Instantiating the SDK + +The SDK client holds the network connection, so the whole application must share a single instance. + +Create a `src/services/kuzzle.js` file: + +<<< ./snippets/kuzzle.js + +:::info +Replace `localhost` with the hostname of the machine running your Kuzzle server. +::: + +## Connecting and loading the messages + +Everything that talks to Kuzzle lives in a single custom hook. Create a `src/useChat.js` file: + +<<< ./snippets/useChat.js + +This hook does the following, once the user has chosen a nickname: + +- [connects](/sdk/js/7/core-classes/kuzzle/connect) the SDK to Kuzzle, +- creates the `chat` [index](/sdk/js/7/controllers/index/create) and the `messages` [collection](/sdk/js/7/controllers/collection/create) if they don't [exist](/sdk/js/7/controllers/index/exists) yet, +- [subscribes](/sdk/js/7/controllers/realtime/subscribe) to the collection, so that every message created by any client is prepended to the local state, +- [searches](/sdk/js/7/controllers/document/search) for the hundred most recent messages to fill the history, +- exposes a `sendMessage` function that [creates](/sdk/js/7/controllers/document/create) a new document. + +:::info +The subscription is opened **before** the history is fetched, and the cleanup function [unsubscribes](/sdk/js/7/controllers/realtime/unsubscribe) when the component unmounts. This way no message can slip through between the two calls, and React's Strict Mode does not leave a dangling room behind. +::: + +Note that `sendMessage` does not touch the local state: the new message comes back through the realtime notification, exactly like the ones sent by the other clients. + +## Displaying the messages + +Create a `src/Message.jsx` component to render a single message: + +<<< ./snippets/Message.jsx + +Then add the styles in `src/App.css`: + +<<< ./snippets/App.css + +## Putting it together + +Finally, rewrite `src/App.jsx`. It asks for a nickname, then displays the message list and the input used to send new ones: + +<<< ./snippets/App.jsx + +Launch the application: + +```bash +npm run dev +``` + +:::success +Open the printed URL in two different browser tabs, pick a different nickname in each one, and send a message: it shows up in both tabs instantly, pushed by Kuzzle. +::: + +## Going further + +Now that you are more familiar with Kuzzle, dive even deeper to learn how to leverage its full capabilities: + +- Follow the [React with Redux](/sdk/js/7/getting-started/react/with-redux) tutorial to move this state into a Redux store +- Discover what this SDK has to offer by browsing other sections of this documentation +- Learn more about Kuzzle [realtime engine](/core/2/guides/main-concepts/realtime-engine) +- Learn how to use the Kuzzle [Admin Console](http://console.kuzzle.io) to manage your users and data +- Learn how to use [Koncorde](/core/2/api/koncorde-filters-syntax) to create incredibly fine-grained and blazing-fast subscriptions +- Follow our guide to learn how to [manage users, and how to set up fine-grained access control](/core/2/guides/main-concepts/permissions) diff --git a/doc/7/getting-started/react/standalone/snippets/App.css b/doc/7/getting-started/react/standalone/snippets/App.css new file mode 100644 index 00000000..86e1142b --- /dev/null +++ b/doc/7/getting-started/react/standalone/snippets/App.css @@ -0,0 +1,28 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 15px; +} + +.messages { + padding: 10px; + margin: 10px; + width: 45vw; + border-radius: 10px; +} + +.fromMe { + text-align: right; + float: right; + margin-left: 49vw; + background-color: rgb(0 40 53 / 30%); +} + +.fromOthers { + text-align: left; + float: left; + margin-right: 49vw; + color: #eeefff; + background-color: rgb(0 40 53 / 90%); +} diff --git a/doc/7/getting-started/react/standalone/snippets/App.jsx b/doc/7/getting-started/react/standalone/snippets/App.jsx new file mode 100644 index 00000000..2d728340 --- /dev/null +++ b/doc/7/getting-started/react/standalone/snippets/App.jsx @@ -0,0 +1,57 @@ +import { useState } from "react"; + +import Message from "./Message"; +import { useChat } from "./useChat"; +import "./App.css"; + +export default function App() { + const [username, setUsername] = useState(null); + const [draft, setDraft] = useState(""); + const { messages, ready, sendMessage } = useChat(username); + + // Ask for a nickname before joining the chat + if (!username) { + return ( +
{ + event.preventDefault(); + setUsername(event.target.elements.username.value.trim() || null); + }} + > + + +
+ ); + } + + return ( +
+
{ + event.preventDefault(); + await sendMessage(draft.trim()); + setDraft(""); + }} + > + setDraft(event.target.value)} + placeholder="Enter your message" + value={draft} + /> + +
+ +
+ {messages.map((message) => ( + + ))} +
+
+ ); +} diff --git a/doc/7/getting-started/react/standalone/snippets/Message.jsx b/doc/7/getting-started/react/standalone/snippets/Message.jsx new file mode 100644 index 00000000..f7b79917 --- /dev/null +++ b/doc/7/getting-started/react/standalone/snippets/Message.jsx @@ -0,0 +1,13 @@ +export default function Message({ message, username }) { + const origin = message.username === username ? "fromMe" : "fromOthers"; + + return ( +
+ + User: {message.username} + + ({new Date(message.createdAt).toLocaleString()}) +

{message.value}

+
+ ); +} diff --git a/doc/7/getting-started/react/standalone/snippets/kuzzle.js b/doc/7/getting-started/react/standalone/snippets/kuzzle.js new file mode 100644 index 00000000..3d01052d --- /dev/null +++ b/doc/7/getting-started/react/standalone/snippets/kuzzle.js @@ -0,0 +1,10 @@ +import { Kuzzle, WebSocket } from "kuzzle-sdk"; + +// Replace 'localhost' with the hostname of your Kuzzle server +const kuzzle = new Kuzzle(new WebSocket("localhost")); + +kuzzle.on("networkError", (error) => { + console.error("Network Error:", error); +}); + +export default kuzzle; diff --git a/doc/7/getting-started/react/standalone/snippets/useChat.js b/doc/7/getting-started/react/standalone/snippets/useChat.js new file mode 100644 index 00000000..40b0df79 --- /dev/null +++ b/doc/7/getting-started/react/standalone/snippets/useChat.js @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import kuzzle from "./services/kuzzle"; + +// Turns a Kuzzle document into the shape our components expect +const toMessage = (document) => ({ + _id: document._id, + value: document._source.value, + username: document._source.username, + createdAt: document._source._kuzzle_info.createdAt, +}); + +export function useChat(username) { + const [messages, setMessages] = useState([]); + const [ready, setReady] = useState(false); + const roomId = useRef(null); + + useEffect(() => { + // Nothing to do until the user has picked a nickname + if (!username) { + return; + } + + let cancelled = false; + + const start = async () => { + await kuzzle.connect(); + + // Creates the index and the collection on first run + if (!(await kuzzle.index.exists("chat"))) { + await kuzzle.index.create("chat"); + await kuzzle.collection.create("chat", "messages"); + } + + // Receives a notification for every new message + roomId.current = await kuzzle.realtime.subscribe( + "chat", + "messages", + {}, + (notification) => { + if (notification.type !== "document") { + return; + } + + if (notification.action !== "create") { + return; + } + + setMessages((previous) => [ + toMessage(notification.result), + ...previous, + ]); + }, + ); + + // Loads the hundred most recent messages + const results = await kuzzle.document.search( + "chat", + "messages", + { sort: { "_kuzzle_info.createdAt": "desc" } }, + { size: 100 }, + ); + + if (cancelled) { + return; + } + + setMessages(results.hits.map(toMessage)); + setReady(true); + }; + + start().catch((error) => console.error(error.message)); + + return () => { + cancelled = true; + + if (roomId.current) { + kuzzle.realtime.unsubscribe(roomId.current); + roomId.current = null; + } + }; + }, [username]); + + const sendMessage = useCallback( + async (value) => { + if (!value) { + return; + } + + await kuzzle.document.create("chat", "messages", { value, username }); + }, + [username], + ); + + return { messages, ready, sendMessage }; +} diff --git a/doc/7/getting-started/react/with-redux/index.md b/doc/7/getting-started/react/with-redux/index.md new file mode 100644 index 00000000..e5bfc251 --- /dev/null +++ b/doc/7/getting-started/react/with-redux/index.md @@ -0,0 +1,126 @@ +--- +code: false +type: page +title: React with Redux +description: Getting started with Kuzzle and React with Redux Toolkit +order: 200 +--- + +# Getting Started with Kuzzle and React with Redux + +This tutorial explains how to use **Kuzzle** with the **Javascript SDK 7**, **React** and **Redux** (through [Redux Toolkit](https://redux-toolkit.js.org/)). + +It builds the same realtime chat as the [standalone React tutorial](/sdk/js/7/getting-started/react/standalone), but the messages live in a Redux store instead of a component state. Messages are stored as **documents** in Kuzzle, and every client is kept up to date through [document notifications](/sdk/js/7/essentials/realtime-notifications#document-messages). + +To follow this tutorial, you must have a Kuzzle Server up and running. Follow these instructions if this is not already the case: [Running Kuzzle](/core/2/guides/getting-started/run-kuzzle). + +:::info +Having trouble? Get in touch with us on [Discord](http://join.discord.kuzzle.io)! +::: + +## Requirements + +- **Node.js** >= 20 ([download page](https://nodejs.org/en/download/)) +- a **running Kuzzle V2 stack** ([instructions here](/core/2/guides/getting-started/run-kuzzle)) + +## Prepare your environment + +Create a React application with [Vite](https://vite.dev/) and install the Javascript SDK along with Redux: + +```bash +npm create vite@latest kuzzle-playground -- --template react +cd kuzzle-playground +npm install +npm install kuzzle-sdk@7 @reduxjs/toolkit react-redux +``` + +:::info +This tutorial uses **Redux Toolkit**, which is the approach [recommended by the Redux team](https://redux.js.org/introduction/why-rtk-is-redux-today). The hand-written action types, switch reducers and `redux-saga` middleware of the older tutorials are no longer needed. +::: + +## Instantiating the SDK + +The SDK client holds the network connection, so the whole application must share a single instance. + +Create a `src/services/kuzzle.js` file: + +<<< ./snippets/kuzzle.js + +:::info +Replace `localhost` with the hostname of the machine running your Kuzzle server. +::: + +## Creating the store + +The store holds the message list. We need a slice with: + +- a `messageReceived` reducer, fed by the realtime subscription, +- a `fetchMessages` thunk that [searches](/sdk/js/7/controllers/document/search) for the existing messages, +- a `sendMessage` thunk that [creates](/sdk/js/7/controllers/document/create) a document. + +Create a `src/state/messagesSlice.js` file: + +<<< ./snippets/messagesSlice.js + +Note that `sendMessage` does not add anything to the store: the new message comes back through the realtime notification, exactly like the ones sent by the other clients. There is a single code path for every message, wherever it comes from. + +Then declare the store itself in `src/state/store.js`: + +<<< ./snippets/store.js + +And make it available to the whole application by wrapping it in a `Provider`, in `src/main.jsx`: + +<<< ./snippets/main.jsx + +## Connecting to Kuzzle + +All the Kuzzle lifecycle lives in a single hook, which dispatches into the store. Create a `src/useKuzzleSync.js` file: + +<<< ./snippets/useKuzzleSync.js + +Once the user has chosen a nickname, this hook: + +- [connects](/sdk/js/7/core-classes/kuzzle/connect) the SDK to Kuzzle, +- creates the `chat` [index](/sdk/js/7/controllers/index/create) and the `messages` [collection](/sdk/js/7/controllers/collection/create) if they don't [exist](/sdk/js/7/controllers/index/exists) yet, +- [subscribes](/sdk/js/7/controllers/realtime/subscribe) to the collection and dispatches `messageReceived` on every document creation, +- dispatches `fetchMessages` to fill the history. + +:::info +The subscription is opened **before** the history is fetched, and the cleanup function [unsubscribes](/sdk/js/7/controllers/realtime/unsubscribe) when the component unmounts. This way no message can slip through between the two calls, and React's Strict Mode does not leave a dangling room behind. +::: + +## Displaying the messages + +Create a `src/Message.jsx` component to render a single message: + +<<< ./snippets/Message.jsx + +Then add the styles in `src/App.css`: + +<<< ./snippets/App.css + +## Putting it together + +Finally, rewrite `src/App.jsx`. It reads the messages from the store with `useSelector`, and sends new ones by dispatching the `sendMessage` thunk: + +<<< ./snippets/App.jsx + +Launch the application: + +```bash +npm run dev +``` + +:::success +Open the printed URL in two different browser tabs, pick a different nickname in each one, and send a message: it shows up in both tabs instantly, pushed by Kuzzle. Install the [Redux DevTools](https://github.com/reduxjs/redux-devtools) extension to watch the `messages/messageReceived` actions as they arrive. +::: + +## Going further + +Now that you are more familiar with Kuzzle, dive even deeper to learn how to leverage its full capabilities: + +- Discover what this SDK has to offer by browsing other sections of this documentation +- Learn more about Kuzzle [realtime engine](/core/2/guides/main-concepts/realtime-engine) +- Learn how to use the Kuzzle [Admin Console](http://console.kuzzle.io) to manage your users and data +- Learn how to use [Koncorde](/core/2/api/koncorde-filters-syntax) to create incredibly fine-grained and blazing-fast subscriptions +- Follow our guide to learn how to [manage users, and how to set up fine-grained access control](/core/2/guides/main-concepts/permissions) diff --git a/doc/7/getting-started/react/with-redux/snippets/App.css b/doc/7/getting-started/react/with-redux/snippets/App.css new file mode 100644 index 00000000..86e1142b --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/App.css @@ -0,0 +1,28 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 15px; +} + +.messages { + padding: 10px; + margin: 10px; + width: 45vw; + border-radius: 10px; +} + +.fromMe { + text-align: right; + float: right; + margin-left: 49vw; + background-color: rgb(0 40 53 / 30%); +} + +.fromOthers { + text-align: left; + float: left; + margin-right: 49vw; + color: #eeefff; + background-color: rgb(0 40 53 / 90%); +} diff --git a/doc/7/getting-started/react/with-redux/snippets/App.jsx b/doc/7/getting-started/react/with-redux/snippets/App.jsx new file mode 100644 index 00000000..e3f5921d --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/App.jsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import Message from "./Message"; +import { useKuzzleSync } from "./useKuzzleSync"; +import { sendMessage } from "./state/messagesSlice"; +import "./App.css"; + +export default function App() { + const [username, setUsername] = useState(null); + const [draft, setDraft] = useState(""); + const dispatch = useDispatch(); + const { list: messages, ready } = useSelector((state) => state.messages); + + useKuzzleSync(username); + + // Ask for a nickname before joining the chat + if (!username) { + return ( +
{ + event.preventDefault(); + setUsername(event.target.elements.username.value.trim() || null); + }} + > + + +
+ ); + } + + return ( +
+
{ + event.preventDefault(); + + const value = draft.trim(); + + if (value) { + dispatch(sendMessage({ value, username })); + } + + setDraft(""); + }} + > + setDraft(event.target.value)} + placeholder="Enter your message" + value={draft} + /> + +
+ +
+ {messages.map((message) => ( + + ))} +
+
+ ); +} diff --git a/doc/7/getting-started/react/with-redux/snippets/Message.jsx b/doc/7/getting-started/react/with-redux/snippets/Message.jsx new file mode 100644 index 00000000..f7b79917 --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/Message.jsx @@ -0,0 +1,13 @@ +export default function Message({ message, username }) { + const origin = message.username === username ? "fromMe" : "fromOthers"; + + return ( +
+ + User: {message.username} + + ({new Date(message.createdAt).toLocaleString()}) +

{message.value}

+
+ ); +} diff --git a/doc/7/getting-started/react/with-redux/snippets/kuzzle.js b/doc/7/getting-started/react/with-redux/snippets/kuzzle.js new file mode 100644 index 00000000..3d01052d --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/kuzzle.js @@ -0,0 +1,10 @@ +import { Kuzzle, WebSocket } from "kuzzle-sdk"; + +// Replace 'localhost' with the hostname of your Kuzzle server +const kuzzle = new Kuzzle(new WebSocket("localhost")); + +kuzzle.on("networkError", (error) => { + console.error("Network Error:", error); +}); + +export default kuzzle; diff --git a/doc/7/getting-started/react/with-redux/snippets/main.jsx b/doc/7/getting-started/react/with-redux/snippets/main.jsx new file mode 100644 index 00000000..3de5c932 --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/main.jsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; + +import App from "./App"; +import store from "./state/store"; + +createRoot(document.getElementById("root")).render( + + + + + , +); diff --git a/doc/7/getting-started/react/with-redux/snippets/messagesSlice.js b/doc/7/getting-started/react/with-redux/snippets/messagesSlice.js new file mode 100644 index 00000000..875cd2cc --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/messagesSlice.js @@ -0,0 +1,53 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +import kuzzle from "../services/kuzzle"; + +// Turns a Kuzzle document into the shape our components expect +export const toMessage = (document) => ({ + _id: document._id, + value: document._source.value, + username: document._source.username, + createdAt: document._source._kuzzle_info.createdAt, +}); + +export const fetchMessages = createAsyncThunk("messages/fetch", async () => { + const results = await kuzzle.document.search( + "chat", + "messages", + { sort: { "_kuzzle_info.createdAt": "desc" } }, + { size: 100 }, + ); + + return results.hits.map(toMessage); +}); + +export const sendMessage = createAsyncThunk( + "messages/send", + async ({ value, username }) => { + await kuzzle.document.create("chat", "messages", { value, username }); + }, +); + +const messagesSlice = createSlice({ + name: "messages", + initialState: { + list: [], + ready: false, + }, + reducers: { + // Dispatched by the realtime subscription, for our own messages as well + messageReceived(state, action) { + state.list.unshift(action.payload); + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchMessages.fulfilled, (state, action) => { + state.list = action.payload; + state.ready = true; + }); + }, +}); + +export const { messageReceived } = messagesSlice.actions; + +export default messagesSlice.reducer; diff --git a/doc/7/getting-started/react/with-redux/snippets/store.js b/doc/7/getting-started/react/with-redux/snippets/store.js new file mode 100644 index 00000000..73d2c7e9 --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/store.js @@ -0,0 +1,9 @@ +import { configureStore } from "@reduxjs/toolkit"; + +import messagesReducer from "./messagesSlice"; + +export default configureStore({ + reducer: { + messages: messagesReducer, + }, +}); diff --git a/doc/7/getting-started/react/with-redux/snippets/useKuzzleSync.js b/doc/7/getting-started/react/with-redux/snippets/useKuzzleSync.js new file mode 100644 index 00000000..e6ff3326 --- /dev/null +++ b/doc/7/getting-started/react/with-redux/snippets/useKuzzleSync.js @@ -0,0 +1,60 @@ +import { useEffect, useRef } from "react"; +import { useDispatch } from "react-redux"; + +import kuzzle from "./services/kuzzle"; +import { + fetchMessages, + messageReceived, + toMessage, +} from "./state/messagesSlice"; + +export function useKuzzleSync(username) { + const dispatch = useDispatch(); + const roomId = useRef(null); + + useEffect(() => { + // Nothing to do until the user has picked a nickname + if (!username) { + return; + } + + const start = async () => { + await kuzzle.connect(); + + // Creates the index and the collection on first run + if (!(await kuzzle.index.exists("chat"))) { + await kuzzle.index.create("chat"); + await kuzzle.collection.create("chat", "messages"); + } + + // Every new message is pushed into the store by the reducer + roomId.current = await kuzzle.realtime.subscribe( + "chat", + "messages", + {}, + (notification) => { + if (notification.type !== "document") { + return; + } + + if (notification.action !== "create") { + return; + } + + dispatch(messageReceived(toMessage(notification.result))); + }, + ); + + dispatch(fetchMessages()); + }; + + start().catch((error) => console.error(error.message)); + + return () => { + if (roomId.current) { + kuzzle.realtime.unsubscribe(roomId.current); + roomId.current = null; + } + }; + }, [dispatch, username]); +} diff --git a/package-lock.json b/package-lock.json index 051930ec..463dbbea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "7.17.1", "license": "Apache-2.0", "dependencies": { + "kuzzle-types": "^1.0.0", "ws": "8.18.3" }, "devDependencies": { @@ -6280,6 +6281,12 @@ "seed-random": "~2.2.0" } }, + "node_modules/kuzzle-types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/kuzzle-types/-/kuzzle-types-1.0.0.tgz", + "integrity": "sha512-IrlI4urlQYtsF+X6UYhZSRkc9K5t7risFVDUC/e/yduFRX501BhODdU5ga6dKw4lgNBQN82NU4wxU9TXAiKNZQ==", + "license": "Apache-2.0" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index c13d0958..4552bcfb 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,9 @@ ], "scripts": { "prepublishOnly": "npm run build", - "test": "npm run test:lint && npm run test:unit && npm run test:functional", + "test": "npm run test:lint && npm run test:unit && npm run test:types && npm run test:functional", "test:unit": "nyc --reporter=text-summary --reporter=lcov mocha", + "test:types": "tsc -p test/types/tsconfig.json", "test:functional": "cucumber-js --exit --fail-fast -c cucumber.config.cjs", "test:lint": "eslint . --fix", "build": "tsc --build tsconfig.json && vite build", @@ -42,6 +43,7 @@ }, "license": "Apache-2.0", "dependencies": { + "kuzzle-types": "^1.0.0", "ws": "8.18.3" }, "devDependencies": { diff --git a/src/types/ApiKey.ts b/src/types/ApiKey.ts index 49bed04a..c950bb3a 100644 --- a/src/types/ApiKey.ts +++ b/src/types/ApiKey.ts @@ -1,36 +1,3 @@ -/** - * ApiKey - * - * @see https://docs.kuzzle.io/core/2/guides/advanced/api-keys/ - */ -export type ApiKey = { - /** - * ApiKey unique ID - */ - _id: string; - /** - * ApiKey content - */ - _source: { - /** - * User kuid - */ - userId: string; - /** - * Expiration date in Epoch-millis format (-1 if the token never expires) - */ - expiresAt: number; - /** - * Original TTL in ms - */ - ttl: number; - /** - * API key description - */ - description: string; - /** - * Authentication token associated with this API key - */ - token: string; - }; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { ApiKey } from "kuzzle-types"; diff --git a/src/types/ArgsDefault.ts b/src/types/ArgsDefault.ts index 0b5c6527..ab92801e 100644 --- a/src/types/ArgsDefault.ts +++ b/src/types/ArgsDefault.ts @@ -1,10 +1,3 @@ -/** - * Generic API action arguments - */ -export interface ArgsDefault { - queuable?: boolean; - - timeout?: number; - - [name: string]: any; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { ArgsDefault } from "kuzzle-types"; diff --git a/src/types/BaseRequest.ts b/src/types/BaseRequest.ts index 419a46f9..f2718217 100644 --- a/src/types/BaseRequest.ts +++ b/src/types/BaseRequest.ts @@ -1,9 +1,3 @@ -import { JSONObject } from "./JSONObject"; - -export interface BaseRequest extends JSONObject { - controller: string; - - action: string; - - body?: JSONObject; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { BaseRequest } from "kuzzle-types"; diff --git a/src/types/Document.ts b/src/types/Document.ts index 339bc939..fe344468 100644 --- a/src/types/Document.ts +++ b/src/types/Document.ts @@ -1,23 +1,9 @@ -import { JSONObject } from "./JSONObject"; -import { KDocumentKuzzleInfo } from "./KDocument"; +import type { DocumentContent, JSONObject } from "kuzzle-types"; -/** - * Kuzzle metadata - * - * @deprecated Use "KDocumentKuzzleInfo" - */ -export interface DocumentMetadata { - _kuzzle_info?: Partial; -} - -/** - * Represents the `_source` property of the document - * - * @deprecated Create an interface extending "KDocumentContent" - */ -export interface DocumentContent extends DocumentMetadata { - [key: string]: JSONObject | any; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). `Document` stays here: it is a class, a runtime +// value, which the types-only kuzzle-types declares as an interface. +export type { DocumentContent, DocumentMetadata } from "kuzzle-types"; /** * Kuzzle document diff --git a/src/types/HttpRoutes.ts b/src/types/HttpRoutes.ts index 0ccb3713..53743cbb 100644 --- a/src/types/HttpRoutes.ts +++ b/src/types/HttpRoutes.ts @@ -1,39 +1,3 @@ -/** - * HTTP routes definition format - * - * @see https://docs.kuzzle.io/core/2/guides/develop-on-kuzzle/api-controllers/#http-routes - * - * @example - * { - * : { - * : { verb: , url: } - * } - * } - * - * { - * 'my-plugin/my-controller': { - * action: { verb: 'GET', url: '/some/url' }, - * action2: { verb: 'GET', url: '/some/url/with/:parameter' } - * } - * } - */ -export type HttpRoutes = { - /** - * Controller name - */ - [controller: string]: { - /** - * Action name - */ - [action: string]: { - /** - * HTTP verb - */ - verb: string; - /** - * URL - */ - url: string; - }; - }; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { HttpRoutes } from "kuzzle-types"; diff --git a/src/types/JSONObject.ts b/src/types/JSONObject.ts index f87e9d53..5207b9a9 100644 --- a/src/types/JSONObject.ts +++ b/src/types/JSONObject.ts @@ -1,4 +1,3 @@ -/** - * An interface representing an object with string key and any value - */ -export type JSONObject = Record; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { JSONObject } from "kuzzle-types"; diff --git a/src/types/KDocument.ts b/src/types/KDocument.ts index 86236094..002c923e 100644 --- a/src/types/KDocument.ts +++ b/src/types/KDocument.ts @@ -1,83 +1,9 @@ -import { JSONObject } from "./JSONObject"; - -/** - * Represents Kuzzle Metadata. - */ -export interface KDocumentKuzzleInfo { - /** - * Kuid of the user who created the document - */ - author: string; - - /** - * Creation date in micro-timestamp - */ - createdAt: number; - - /** - * Kuid of the user who last updated the document - */ - updater: string | null; - - /** - * Update date in micro-timestamp - */ - updatedAt: number | null; -} - -/** - * Base interface for a Kuzzle document content - */ -export interface KDocumentContent { - _kuzzle_info?: KDocumentKuzzleInfo; -} - -/** - * Generic kuzzle document content - */ -export interface KDocumentContentGeneric extends KDocumentContent, JSONObject {} - -/** - * Represents a Kuzzle document - * - * Type argument represents the document content in the "_source" property - */ -export interface KDocument { - /** - * Unique ID - */ - _id: string; - - /** - * Content - */ - _source: TKDocumentContent; - - created?: boolean; - - _version?: number; -} - -/** - * Represents a Kuzzle document retrieved from search - */ -export interface KHit< - TKDocumentContent extends KDocumentContent, -> extends KDocument { - /** - * Elasticsearch relevance score - */ - _score: number; - - /** - * Document index - * Present only in the case of a multi search - */ - index?: string; - - /** - * Document collection - * Present only in the case of a multi search - */ - collection?: string; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { + KDocumentKuzzleInfo, + KDocumentContent, + KDocumentContentGeneric, + KDocument, + KHit, +} from "kuzzle-types"; diff --git a/src/types/Mappings.ts b/src/types/Mappings.ts index 61193788..d7d1615e 100644 --- a/src/types/Mappings.ts +++ b/src/types/Mappings.ts @@ -1,65 +1,3 @@ -import { JSONObject } from "./JSONObject"; - -type PropertyObject = { - properties?: MappingsProperties; -}; - -type PropertyDynamic = { - /** - * Dynamic mapping policy - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#mappings-dynamic-policy - */ - dynamic?: "true" | "false" | "strict"; -}; - -type PropertyType = { - [name: string]: { type?: string } | PropertyObject | JSONObject; -}; - -export type MappingsProperties = - | PropertyObject - | PropertyDynamic - | PropertyType; - -/** - * Collection mappings definition - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#collection-mappings - * - * @example - * ``` - * { - * properties: { - * name: { type: 'keyword' }, - * address: { - * properties: { - * zipcode: { type: 'integer' } - * } - * } - * } - * } - * ``` - */ -export type CollectionMappings = { - /** - * Collection metadata - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#mappings-metadata - */ - _meta?: JSONObject; - - /** - * Properties types definition - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#mappings-properties - */ - properties?: MappingsProperties; - - /** - * Dynamic mapping policy - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#mappings-dynamic-polic - */ - dynamic?: "true" | "false" | "strict"; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { MappingsProperties, CollectionMappings } from "kuzzle-types"; diff --git a/src/types/Notification.ts b/src/types/Notification.ts index c45c6139..9dadffe6 100644 --- a/src/types/Notification.ts +++ b/src/types/Notification.ts @@ -1,125 +1,10 @@ -import { JSONObject } from "./JSONObject"; -import { KDocumentContentGeneric } from "."; - -/** - * Enum for notification types - */ -export type NotificationType = "document" | "user" | "TokenExpired"; - -export interface BaseNotification { - /** - * Notification type - */ - type: NotificationType; - - /** - * Controller that triggered the notification - */ - controller: string; - /** - * Action that triggered the notification - */ - action: string; - /** - * Event type according to API action - */ - event: "write" | "delete" | "publish"; - /** - * Index name - */ - index: string; - /** - * Collection name - */ - collection: string; - /** - * Network protocol used to trigger the notification - */ - protocol: string; - /** - * Subscription channel identifier. - * Can be used to link a notification to its corresponding subscription - */ - room: string; - /** - * Timestamp of the event, in Epoch-millis format - */ - timestamp: number; - /** - * Request volatile data - * @see https://docs.kuzzle.io/core/2/guides/essentials/volatile-data/ - */ - volatile: JSONObject; -} - -/** - * Notification triggered by a document change. - * (create, update, delete) - */ -export interface DocumentNotification< - TDocContent extends KDocumentContentGeneric = KDocumentContentGeneric, -> extends BaseNotification { - /** - * Updated document that triggered the notification - */ - result: { - /** - * The message or full document content. - */ - _source: TDocContent; - /** - * Document unique ID. - * `null` if the notification is from a real-time message. - */ - _id: string | null; - /** - * List of fields that have been updated (only available on document partial updates). - */ - _updatedFields?: string[]; - }; - /** - * State of the document regarding the scope (`in` or `out`) - */ - scope: "in" | "out"; - - type: "document"; -} - -/** - * Notification triggered by an user joining or leaving a subscription room - */ -export interface UserNotification extends BaseNotification { - /** - * Tell wether an user leave or join the subscription room (`in` or `out`) - */ - user: "in" | "out"; - - /** - * Contains the actual number of users in the subscription room - */ - result: { - /** - * Updated users count sharing the same subscription room - */ - count: number; - }; - - type: "user"; -} - -export interface ServerNotification extends BaseNotification { - /** - * Server message explaining why this notifications has been triggered. - */ - message: string; - - type: "TokenExpired"; -} - -/** - * Real-time notifications sent by Kuzzle. - */ -export type Notification = - | DocumentNotification - | UserNotification - | ServerNotification; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { + NotificationType, + BaseNotification, + DocumentNotification, + UserNotification, + ServerNotification, + Notification, +} from "kuzzle-types"; diff --git a/src/types/ProfilePolicy.ts b/src/types/ProfilePolicy.ts index 876cd643..211e39c0 100644 --- a/src/types/ProfilePolicy.ts +++ b/src/types/ProfilePolicy.ts @@ -1,42 +1,3 @@ -/** - * A profile policy is composed of a roleId to define API rights - * and an optional array of restrictions on index and collections - * - * @example - * { - * "roleId": "editor", - * "restrictedTo": { - * "index": "blog", - * "collections": [ - * "articles" - * ] - * } - * } - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/permissions/#policies - */ -export type ProfilePolicy = { - /** - * Role unique ID used by this policy - */ - roleId: string; - - /** - * Optional array of restrictions on which the rights are gonne be applied - */ - restrictedTo?: [ - { - /** - * Index name. - * Rights will only be applied on this index. - */ - index: string; - - /** - * Collection names. - * Rights will only be applied on those collections. - */ - collections?: Array; - }, - ]; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { ProfilePolicy } from "kuzzle-types"; diff --git a/src/types/RequestPayload.ts b/src/types/RequestPayload.ts index 7efe9065..bc004db5 100644 --- a/src/types/RequestPayload.ts +++ b/src/types/RequestPayload.ts @@ -1,55 +1,3 @@ -import { JSONObject } from "./JSONObject"; - -/** - * Kuzzle API request payload - * - * @see https://docs.kuzzle.io/core/2/api/payloads/request - */ -export interface RequestPayload { - /** - * API controller name - */ - controller: string; - - /** - * API action name - */ - action: string; - - /** - * Index name - */ - index?: string; - - /** - * Collection name - */ - collection?: string; - - /** - * Document unique identifier - */ - _id?: string; - - /** - * Authentication token - */ - jwt?: string; - - /** - * Volatile data - */ - volatile?: JSONObject; - - /** - * Request body - */ - body?: JSONObject; - - /** - * Request unique identifier - */ - requestId?: string; - - [key: string]: any; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { RequestPayload } from "kuzzle-types"; diff --git a/src/types/ResponsePayload.ts b/src/types/ResponsePayload.ts index f216fd48..7df186bf 100644 --- a/src/types/ResponsePayload.ts +++ b/src/types/ResponsePayload.ts @@ -1,103 +1,3 @@ -import { JSONObject } from "./JSONObject"; - -/** - * Kuzzle API response payload - * - * @see https://docs.kuzzle.io/core/2/api/payloads/response - */ -export interface ResponsePayload { - /** - * API controller name - */ - controller: string; - - /** - * API action name - */ - action: string; - - /** - * Index name - */ - index?: string; - - /** - * Collection name - */ - collection?: string; - - /** - * Document unique identifier - */ - _id?: string; - - /** - * Array of deprecation warnings (hidden if NODE_ENV=production) - */ - deprecations?: Array<{ - /** - * Deprecation description - */ - message: string; - - /** - * Deprecated since this version - */ - version: string; - }>; - - /** - * API error - */ - error?: { - /** - * Error human readable identifier - */ - id: string; - - /** - * Error identifier - */ - code: number; - - /** - * Error message - */ - message: string; - - /** - * HTTP status error code - */ - status: number; - - /** - * Error stacktrace (only if NODE_ENV=development) - */ - stack?: string; - }; - - /** - * Request unique identifier - */ - requestId: string; - - /** - * API action result - */ - result: TResult; - - /** - * HTTP status code - */ - status: number; - - /** - * Volatile data - */ - volatile?: JSONObject; - - /** - * Room unique identifier - */ - room?: string; -} +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { ResponsePayload } from "kuzzle-types"; diff --git a/src/types/RoleRightsDefinition.ts b/src/types/RoleRightsDefinition.ts index 7b46de6f..550bdd4b 100644 --- a/src/types/RoleRightsDefinition.ts +++ b/src/types/RoleRightsDefinition.ts @@ -1,36 +1,3 @@ -/** - * Role list of rights definition for controllers and actions. - * - * @example - * - * { - * auth: { - * actions: { - * getCurrentUser: true, - * getMyCredentials: true, - * getMyRights: true, - * logout: true - * } - * }, - * realtime: { - * actions: { - * "*": true - * } - * } - * } - * - * @see https://docs.kuzzle.io/core/2/guides/main-concepts/permissions/#roles - */ -export type RoleRightsDefinition = { - /** - * API controller name - */ - [controller: string]: { - actions: { - /** - * API action name - */ - [action: string]: boolean; - }; - }; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { RoleRightsDefinition } from "kuzzle-types"; diff --git a/src/types/mRequests.ts b/src/types/mRequests.ts index ed21f3d9..0bf6edc2 100644 --- a/src/types/mRequests.ts +++ b/src/types/mRequests.ts @@ -1,53 +1,10 @@ -import { KDocumentContentGeneric } from "./KDocument"; - -export type mCreateRequest = - Array<{ - /** - * Document unique identifier - */ - _id?: string; - - /** - * Document content - */ - body: Partial; - }>; - -export type mCreateOrReplaceRequest< - TKDocumentContent extends KDocumentContentGeneric, -> = Array<{ - /** - * Document unique identifier - */ - _id: string; - - /** - * Document content - */ - body: Partial; -}>; - -export type mReplaceRequest = - mCreateOrReplaceRequest; -export type mUpdateRequest = - mCreateOrReplaceRequest; - -export type mUpsertRequest = - Array<{ - /** - * Document unique identifier - */ - _id: string; - - /** - * Document partial changes - */ - changes: Partial; - - /** - * Document fields to add to the "update" part if the document is created - */ - default?: Partial; - }>; - -export type mDeleteRequest = string[]; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { + mCreateRequest, + mCreateOrReplaceRequest, + mReplaceRequest, + mUpdateRequest, + mUpsertRequest, + mDeleteRequest, +} from "kuzzle-types"; diff --git a/src/types/mResponses.ts b/src/types/mResponses.ts index 3384bc98..cfa3acc1 100644 --- a/src/types/mResponses.ts +++ b/src/types/mResponses.ts @@ -1,104 +1,10 @@ -import { JSONObject } from "./JSONObject"; - -type mResponseErrors = Array<{ - /** - * Original document that caused the error - */ - document: { - _id: string; - body: JSONObject; - }; - - /** - * HTTP error status code - */ - status: number; - - /** - * Human readable reason - */ - reason: string; -}>; - -export type mCreateResponse = { - /** - * Array of succeeded operations - */ - successes: Array<{ - /** - * Document unique identifier - */ - _id: string; - - /** - * Document content - */ - _source: JSONObject; - - /** - * Document version number - */ - _version: number; - - /** - * `true` if document is created - */ - created: boolean; - }>; - - /** - * Arrays of errored operations - */ - errors: mResponseErrors; -}; - -export type mCreateOrReplaceResponse = mCreateResponse; -export type mUpsertResponse = mCreateResponse; - -export type mReplaceResponse = { - /** - * Array of succeeded operations - */ - successes: Array<{ - /** - * Document unique identifier - */ - _id: string; - - /** - * Document content - */ - _source: JSONObject; - - /** - * Document version number - */ - _version: number; - }>; - - /** - * Arrays of errored operations - */ - errors: mResponseErrors; -}; - -export type mUpdateResponse = mReplaceResponse; - -export type mDeleteResponse = { - /** - * IDs of deleted documents - */ - successes: string[]; - - errors: Array<{ - /** - * Document unique identifier - */ - _id: string; - - /** - * Human readable reason - */ - reason: string; - }>; -}; +// Moved to kuzzle-types, the API contract shared by kuzzle and kuzzle-sdk +// (Kuzzle ADR-0002 step 03). Kept so that imports of this path still work. +export type { + mCreateResponse, + mCreateOrReplaceResponse, + mUpsertResponse, + mReplaceResponse, + mUpdateResponse, + mDeleteResponse, +} from "kuzzle-types"; diff --git a/test/types/kuzzle-types.ts b/test/types/kuzzle-types.ts new file mode 100644 index 00000000..d95e3057 --- /dev/null +++ b/test/types/kuzzle-types.ts @@ -0,0 +1,87 @@ +// The API contract types moved to kuzzle-types (Kuzzle ADR-0002 step 03): the +// SDK must still export every one of them, under the same name, as exactly +// kuzzle-types' type — kuzzle-types itself asserts those are the types +// kuzzle-sdk 7.17.1 exported. `Document` is the exception: the SDK keeps its +// class, whose instances kuzzle-types' interface describes. +// Type-checked by `npm run test:types` (after `npm run build`); nothing here runs. +import type * as Types from "kuzzle-types"; + +// The built declarations — what users get — not the non-strict sources. +import type * as Sdk from "../../out"; + +/** `true` only if A and B are identical types (not merely assignable). */ +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +/** Assignable both ways. */ +type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +function assert(): T | void {} + +type Content = Types.KDocumentContent & { name: string }; + +// src/types/ApiKey.ts +assert>(); +// src/types/ArgsDefault.ts +assert>(); +// src/types/BaseRequest.ts +assert>(); +// src/types/Document.ts +assert>(); +assert>(); +// src/types/HttpRoutes.ts +assert>(); +// src/types/JSONObject.ts +assert>(); +// src/types/KDocument.ts +assert>(); +assert>(); +assert>(); +assert, Types.KDocument>>(); +assert, Types.KHit>>(); +// src/types/Mappings.ts +assert>(); +assert>(); +// src/types/Notification.ts +assert>(); +assert>(); +assert< + Equals, Types.DocumentNotification> +>(); +assert>(); +assert>(); +assert>(); +// src/types/ProfilePolicy.ts +assert>(); +// src/types/RequestPayload.ts +assert>(); +// src/types/ResponsePayload.ts +assert, Types.ResponsePayload>>(); +// src/types/RoleRightsDefinition.ts +assert>(); +// src/types/mRequests.ts +assert, Types.mCreateRequest>>(); +assert< + Equals< + Sdk.mCreateOrReplaceRequest, + Types.mCreateOrReplaceRequest + > +>(); +assert, Types.mReplaceRequest>>(); +assert, Types.mUpdateRequest>>(); +assert, Types.mUpsertRequest>>(); +assert>(); +// src/types/mResponses.ts +assert>(); +assert>(); +assert>(); +assert>(); +assert>(); +assert>(); + +// src/types/Document.ts — the class stays in the SDK, a runtime value. +assert>(); +assert>(); +export const document: typeof Sdk.Document = {} as typeof Sdk.Document; diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 00000000..2986085b --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["./**/*.ts"] +}