A lightweight, extensible HTTP client for modern JavaScript applications
Lissa is a powerful yet minimal HTTP library that brings simplicity back to API interactions. Built on the native Fetch API, it offers a fluent, promise-based interface with zero dependencies while providing advanced features like intelligent retries, request deduplication, and progress tracking.
Whether you're building a complex web application or a simple Node.js service, Lissa adapts to your needs with its plugin-driven architecture and universal compatibility.
- Features
- Installation
- Usage
- API
- Fluent API
- Built-in Plugins
- Error Types
- Examples
- Browser Support
- License
- Promise-based fluent API: Modern async/await support with clean, intuitive syntax
- Universal: Works seamlessly in both Node.js and browser environments
- Plugin Architecture: Easily extend functionality with plugins (dedupe and retry are built-in)
- Request/Response Hooks: Powerful hooks for customizing request/response handling
- File Operations: Built-in support for file uploads and downloads with progress tracking
- Error Handling: Custom error classes for robust error management
- TypeScript Support: Full TypeScript definitions included
- Lightweight: No dependencies, built on native Fetch API
- Flexible Configuration: Instance-based configuration with inheritance and extension
npm install lissaIn this documentation we reference the Lissa class as Lissa and a reference to a Lissa instance as lissa.
import Lissa from "lissa";
// Direct function call
const { data } = await Lissa("https://api.example.com/data");
// Use HTTP methods
const { data: users } = await Lissa.get("https://api.example.com/users");
const { data: newUser } = await Lissa.post("https://api.example.com/users", {
name: "John Doe",
email: "john@example.com"
});
// Create a configured instance
const lissa = Lissa.create({
baseURL: "https://api.example.com",
headers: { "Authorization": "Bearer token" }
});
// Use HTTP methods on the instance
const { data: user } = await lissa.get("/users/1");
const { data: newPost } = await lissa.post("/posts", { title: "Hello", body: "World" });import Lissa from "lissa";
const lissa = Lissa.create("https://api.example.com");
lissa.use(Lissa.retry());
const { data } = await lissa.get("/data");import Lissa from "lissa";
const lissa = Lissa.create({
baseURL: CONFIG.API_ADDRESS,
headers: {
"X-Api-Key": CONFIG.API_KEY,
},
paramsSerializer: "extended",
credentials: "include",
});
lissa.use(Lissa.dedupe());
lissa.use(Lissa.retry({
beforeRetry({ attempt }) {
if (attempt !== 2) return;
Notify.error("The connection to the server was interrupted!");
},
onSuccess() {
dismissDisconnectError();
},
}));
lissa.onError(async (error) => {
if (error.name === "ResponseError" && error.status === 401) {
await Session.logout();
Notify.info("Your session has expired! Please log in again.");
error.handled = true;
throw error;
}
});Any property that is not listed below will get handed over to the underlying fetch call as is. Checkout https://developer.mozilla.org/en-US/docs/Web/API/RequestInit for available properties.
| Property | Type | Default Value | Description |
|---|---|---|---|
| baseURL | string | "" | Will be prepended to "url". |
| url | string | "" | The resource to fetch. |
| method | string | "get" | A http request method |
| authenticate | { username, password } | undefined | Basic authentication |
| headers | Headers | {} | HTTP headers |
| params | object | {} | Query params |
| paramsSerializer | "simple", "extended" or Function | "simple" | How to serialize the query params |
| urlBuilder | "simple", "extended" or Function | "simple" | How to build the final fetch url from the defined "baseURL" and "url" |
| responseType | "json", "text", "file" or "raw" | "json" | The type of data that the server will respond with |
| timeout | number | undefined | Specify the number of milliseconds before the request gets aborted |
| signal | AbortSignal | undefined | Cancel/Abort running requests |
| body | object, buffer, stream, file, etc. | undefined | Request body |
Set the paramsSerializer option to "simple", "extended" or a custom query string params serializer function. Inspired by express "simple" and "extended" serializes the params like "node:querystring" or qs. It is set to "simple" by default. A custom function will receive an object of query param keys and their values, and must return the complete query string.
Set the urlBuilder option to "simple", "extended" or a custom build function.
- "simple" simply concatenates baseURL and url as strings (default)
- "extended" is using the URL constructor new URL(url, baseURL);
- A custom function will receive url and baseURL, and must return the complete url as string or URL instance
Make sure to not forget a needed slash using "simple". If using "extended" be careful with leading and trailing slashes in urls, the baseURL and also with sub paths in the baseURL. For example new URL("todos", "http://api.example.com/v2") and new URL("/todos", "http://api.example.com/v2/") both results in a fetch to "http://api.example.com/todos". Only new URL("todos", "http://api.example.com/v2/") will result in a fetch to the expected "http://api.example.com/v2/todos".
Every request returns a promise which gets fulfilled into a result object or rejected into a result error. Both provide the following properties:
| Property | Type | Description |
|---|---|---|
| options | object | The options used to make the request |
| request | object | The arguments with which the fetch got called |
| response | object | The underlying fetch response |
| headers | Headers | The response headers |
| status | number | The response status code |
| data | object | The response data |
Performs a general fetch request. Specify method, body, headers and more in the given options object.
Lissa(url);
Lissa(url, options);A Promise that resolves to a result object or rejects into a result error.
A Lissa instance can be created with base options that apply to or get merged into every request.
Lissa.create();
Lissa.create(baseURL);
Lissa.create(options);
Lissa.create(baseURL, options);A new lissa instance.
lissa.get(url);
lissa.get(url, options);
// Can also be called as static method
Lissa.get()lissa.post(url);
lissa.post(url, body);
lissa.post(url, body, options);
// Can also be called as static method
Lissa.post()lissa.put(url);
lissa.put(url, body);
lissa.put(url, body, options);
// Can also be called as static method
Lissa.put()lissa.patch(url);
lissa.patch(url, body);
lissa.patch(url, body, options);
// Can also be called as static method
Lissa.patch()lissa.delete(url);
lissa.delete(url, options);
// Can also be called as static method
Lissa.delete()lissa.request(options);
// Can also be called as static method
Lissa.request()Upload files with optional progress tracking
lissa.upload(file, url);
lissa.upload(file, url, onProgress);
lissa.upload(file, url, onProgress, options);
// Can also be called as static method
Lissa.upload()// Basic upload
await lissa.upload(file, "/upload");
// Upload with progress tracking
await lissa.upload(file, "/upload", (uploaded, total) => {
console.log(`Upload progress: ${Math.round(uploaded / total * 100)} %`);
});Download files with optional progress tracking
lissa.download(url);
lissa.download(url, onProgress);
lissa.download(url, onProgress, options);
// Can also be called as static method
Lissa.download()// Basic download
const { data: file } = await lissa.download("/file.pdf");
// Download with progress tracking
const { data: file } = await lissa.download("/file.pdf", (downloaded, total) => {
console.log(`Download progress: ${Math.round(downloaded / total * 100)} %`);
});Easily add functionality with plugins like Lissa.dedupe() and Lissa.retry()
lissa.use(plugin);Modify options before a request is processed. Returning a new options object is also possible, keep in mind a new options object will not get merged with defaults again.
lissa.beforeRequest((options) => {
options.headers.set("X-Timestamp", Date.now());
});Modify the final fetch arguments for special edge cases. Returning a new object is also possible.
lissa.beforeFetch(({ url, options }) => {
console.log("Calling fetch(url, options)", { url, options });
});Handle successful responses. If a value gets returned in this hook, every hook registered after this hook getting skipped and the request promise fulfills with this return value. If an error gets thrown or returned the request promise rejects with this error.
lissa.onResponse((result) => {
console.log("Response received:", result.status);
});Handle connection, response or abort errors. If a value gets returned in this hook, every hook registered after this hook getting skipped and the request promise fulfills with this return value. If an error gets thrown or returned the request promise rejects with this error.
lissa.onError((error) => {
if (error.name === "ResponseError" && error.status === 401) {
redirectToLogin();
}
});Creates a new instance with merged options
const apiClient = lissa.extend({
headers: { "Authorization": "Bearer token" }
});Creates a new instance with added basic authentication
const authenticatedClient = lissa.authenticate("username", "password");The promises returned by the requests aren't just promises. They are instances of the LissaRequest class which allows a fluent API syntax. An instance of LissaRequest is referenced as request.
await lissa.get("/data").baseURL("https://api2.example.com");await lissa.request(options).url("/data2");await lissa.get("/data").method("delete"); // :Dawait lissa.get("/data").headers({ "X-Foo": "bar" });await lissa.get("/data").authenticate("username", "password");await lissa.get("/data").params({ foo: "bar" });await lissa.post("/data").body({ foo: "bar" });Attaches an AbortSignal.timeout(...) signal to the request
await lissa.get("/data").timeout(30 * 1000);Attaches an AbortSignal to the request
await lissa.get("/data").signal(abortController.signal);By default every response gets parsed as json
await lissa.get("/plain-text").responseType("text");For special edge cases you can access the options directly
const request = lissa.get("/data");
request.options.headers.delete("X-Foo");
const result = await request;A LissaRequest provides some other maybe useful properties and events for more special edge cases.
const request = lissa.get("/data");
request.status; // "pending", "fulfilled" or "rejected"
request.value; // The result object if promise fulfills
request.reason; // The result error if promise rejects
request.on("resolve", (value) => console.log(value));
request.on("reject", (reason) => console.log(reason));
request.on("settle", ({ status, value, reason }) => console.log({ status, value, reason }));
const result = await request;Automatically retry failed requests. The retry delay is 1 sec on first retry, 2 sec on second retry, etc., but max 5 sec. The default options are different for browsers and node. It is most likely that we want to connect to our own service in a browser and to vendor services in node. The node default is 3 retries on every error type. The browser default is Infinity for all error types except for server errors which is 0 (no retries).
lissa.use(Lissa.retry({
onConnectionError: Infinity,
onGatewayError: Infinity,
on429: Infinity,
onServerError: 0,
}));Decide if the occurred error should trigger a retry.
The given errorType helps preselecting error types. Return false to not
trigger a retry. Return nothing if the given errorType is correct. Return
a string to redefine the errorType or use a custom one. The number of
maximum retries can be configured as on${errorType}. Return "CustomError"
and define the retries as { onCustomError: 3 }
Lissa.retry({
onCustomError: 5,
shouldRetry(errorType, error) {
if (error.status === 999) return "CustomError";
if (errorType === "429" && !error.headers.has("Retry-After")) return false;
return errorType; // optional
},
})Hook into the retry logic after the retry is triggered and before the delay is awaited. Use beforeRetry e.g. if you want to change how long the delay should be or to notify a customer that the connection is lost.
Lissa.retry({
// Return new object
beforeRetry({ attempt, delay }, error) {
if (error.status === 429) return { attempt, delay: delay * attempt };
},
// Or change existing object
beforeRetry(retry, error) {
if (error.status === 429) retry.delay = retry.attempt * 1234;
},
})Hook into the retry logic after the delay is awaited and before the request gets resent. Use onRetry e.g. if you want to log that a retry is running now
Lissa.retry({
onRetry({ attempt, delay }, error) {
console.log("Retry attempt", attempt, "for", error.options.method, error.options.url);
},
})Hook into the retry logic after a request was successful. Use onSuccess e.g. if you want to dismiss a connection lost notification
Lissa.retry({
onSuccess({ attempt, delay }, result) {
console.log("Retry successful after attempt", attempt, "for", result.options.method, result.options.url, "-", result.status);
},
})Prevent duplicate requests by aborting leading or trailing identical requests. By default it only aborts leading get requests to the same endpoint ignoring query string params. Dedupe can be forced or disabled per request by adding dedupe to the request options setting the strategy or false.
lissa.use(Lissa.dedupe({
methods: ["get"], // Pre-filter by HTTP method
getIdentifier: options => options.method + options.url, // Identify request
defaultStrategy: "leading", // or trailing
}));
lissa.get("/data"); // Getting aborted on dedupe strategy "leading" (default)
lissa.get("/data"); // Getting aborted on dedupe strategy "trailing"
lissa.get("/data", { dedupe: false }); // Dedupe logic getting skipped
lissa.get("/data", { dedupe: "trailing" }); // Force dedupe with the given strategylissa.use(Lissa.dedupe({
// We use our own property to identify requests. A falsy identifier results in skipping dedupe logic
getIdentifier: options => options.origin,
// Abort trailing requests / Only the first request gets through
defaultStrategy: "trailing",
}));
lissa.get("/todos", {
origin: "todo_table_retry_btn",
dedupe: "trailing", // Can be omitted - Already applied by defaultStrategy here
});
lissa.get("/todos", {
origin: "todo_table_filter_input",
dedupe: "leading", // Override defaultStrategy - abort previous requests
// Example filter with params
params: {
search: "a search string",
status: "done",
orderBy: "created",
},
});Error types for different failure scenarios:
- ResponseError: HTTP errors (4xx, 5xx status codes)
- TimeoutError: Request timed out
- AbortError: Request got aborted
- ConnectionError: Network connectivity issues
All errors include the original request options. Response errors include response data and status information.
// Using params option
const { data } = await lissa.get("/posts", {
params: { userId: 1, limit: 10 }
});
// Using fluent API
const { data } = await lissa.get("/posts").params({ userId: 1 });// Add dynamic header to all requests
lissa.beforeRequest((options) => {
options.headers.set("X-NOW", Date.now());
});
// Log all responses
lissa.onResponse(({ options, status }) => {
console.log(`${options.method.toUpperCase()} ${options.url} - ${status}`);
});
// Handle errors globally
lissa.onError((error) => {
if (error.status === 500) {
Notify.error("An unexpected error occurred! Please try again later.");
}
});const fileInput = document.querySelector("#file-input");
const file = fileInput.files[0];
await lissa.upload(file, "/upload", (uploaded, total) => {
const percent = Math.round(uploaded / total * 100);
console.log(`Upload progress: ${percent} %`);
updateProgressBar(percent);
});try {
const { data } = await lissa.get("/data");
console.log(data);
} catch (error) {
if (error.name === "ResponseError") {
console.error(`HTTP Error ${error.status}: ${error.data}`);
} else if (error.name === "TimeoutError") {
console.error("Request timed out");
} else if (error.name === "AbortError") {
console.error("Request got aborted");
} else if (error.name === "ConnectionError") {
console.error("Could not connect to target server");
} else {
console.error("Most likely a TypeError:", error);
}
}// API client with authentication
const apiClient = Lissa.create({
baseURL: "https://api.example.com",
headers: {
"Authorization": "Bearer token",
}
});
// Public client without authentication
const publicClient = Lissa.create({
baseURL: "https://public-api.example.com"
});
apiClient.use(Lissa.retry());
publicClient.use(Lissa.dedupe());See the examples/ directory for more usage examples, including browser-specific code and advanced plugin usage.
Lissa works in all modern browsers that support the following APIs:
- Fetch API - For making HTTP requests
- Promises - For async operations
- Headers API - For request/response header manipulation
- URL API - For URL construction and parsing
- AbortController/AbortSignal - For request cancellation and timeouts
- TextDecoderStream - For body processing
- File API - For file handling, uploads and downloads with proper metadata
- FormData API - For multipart form uploads
- TransformStream - For download progress tracking with fetch
- Chrome: 124+ (full support)
- Firefox: 124+ (full support)
- Safari: 17.4+ (full support)
- Edge: 124+ (full support)
Requires Node.js 20.3+.
This project is licensed under the MIT License. See the LICENSE file for details.