Cloudflare offers an excellent product called Workers KV, a globally replicated key-value storage layer. It can handle millions of keys, each of which can be accessed with extremely low latency in Worker scripts, no matter where the request comes from. The Workers KV is amazing – it's also priced, including a generous free tier.
However, as a long-term user of the Cloudflare product line, I found that one thing was missing: local introspection . I often have thousands or even hundreds of thousands of keys in my application and I often want to have a way to query all the data, sort it, or just see what actually exists.
Recently, I had the honor to join Cloudflare! More importantly, I joined this quarter before the “Quick Win Week” (aka the Weekly Hackathon). Since I haven't accumulated enough backlog of work (not yet), trust me, I will seize this opportunity to fulfill my wishes.
Back to the point, let me tell you how I built the Workers KV GUI, a cross-platform desktop application built using Svelte, Redis, and Rust.
As a web developer, this is a familiar link. I would love to call it the "simple part", but given that you can use any and all HTML, CSS and JavaScript frameworks, libraries or patterns, choosing paralysis is easy to happen…it may also be familiar. If you have a front-end tech stack you like, that's great, use it! For this app, I chose to use Svelte because for me it really makes things simple and keeps them simple.
Additionally, as web developers, we want to carry all the tools with us. Of course you can! Again, this phase of the project is no different from a typical web application development cycle. You can expect to run yarn dev (or some variant) as your primary command and feel at home. To keep the "simple" theme, I chose to use SvelteKit, Svelte's official framework and toolkit for building applications. It includes an optimized build system, an excellent developer experience (including HMR!), a file system-based router, and all the features Svelte itself offers.
As a framework, especially one that takes care of the tools myself, SvelteKit allows me to think purely about my application and its needs. In fact, the only thing I need to do in terms of configuration is to tell SvelteKit that I want to build a single page application (SPA) that runs only on the client. In other words, I have to explicitly opt out of SvelteKit assuming that I want a server, which is actually a reasonable assumption, as most applications can benefit from server-side rendering. This is as simple as attaching the @sveltejs/adapter-static package, a configuration preset created specifically for this purpose. After installation, my entire configuration file looks like this:
<code>// svelte.config.js import preprocess from 'svelte-preprocess'; import adapter from '@sveltejs/adapter-static'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: preprocess(), kit: { adapter: adapter({ fallback: 'index.html' }), files: { template: 'src/index.html' } }, }; export default config;</code>
Changes to index.html are my personal preference. SvelteKit uses app.html as the default basic template, but old habits are difficult to change.
In just a few minutes my toolchain already knew it was building a SPA, already had a router ready, and a development server ready to be available. Additionally, TypeScript, PostCSS, and/or Sass support is also available due to svelte-preprocess if I want (and I do want it). Ready!
The application requires two views:
In the SvelteKit world, this translates into two "routers", which SvelteKit stipulates that these routes should exist as src/routes/index.svelte (home page) and src/routes/viewer.svelte (data viewer page). In a real web application, the second route will be mapped to the /viewer URL. While this is still the case, I know my desktop application won't have a navigation bar, which means the URL will be invisible...it means it doesn't matter how I name this route, as long as it makes sense to me.
The contents of these files are mostly irrelevant, at least for this article. For those who are curious, the entire project is open source, and if you are looking for Svelte or SvelteKit examples, you are welcome to check it out. At the risk of being like a broken record, the point here is that I'm building a normal web application.
At this point, I just design my views and throw fake, hard-coded data around until I get something that looks valid. I stayed here for about two days until everything looked beautiful and all the interactivity (button clicks, form submissions, etc.) was perfected. I would call it a "runable" application or model.
At this time, a fully functional SPA already exists. It runs in a web browser – and is developed in a web browser. Perhaps contrary to intuition, this makes it a perfect candidate for desktop applications! But how to do it?
You may have heard of Electron. It is the most famous tool for building cross-platform desktop applications using web technology. There are a lot of very popular and successful applications built with it: Visual Studio Code, WhatsApp, Atom, and Slack, to name a few. It works by bundling your web resources with its own Chromium installation and its own Node.js runtime. In other words, when you install an Electron-based application, it comes with an extra Chrome browser and a complete set of programming languages (Node.js). These are embedded in the application content and cannot be avoided because these are dependencies of the application, ensuring that it runs consistently anywhere. As you might imagine, there are some tradeoffs for this approach – the application is quite large (i.e. over 100MB) and uses a lot of system resources to run. To use the app, the background runs a brand new/separate Chrome – which is not exactly the same as opening a new tab.
Fortunately, there are some alternatives – I evaluated Svelte NodeGui and Tauri. Both options provide significant application size and utilization savings by relying on native renderers provided by the operating system rather than embedding a copy of Chrome. NodeGui does this by relying on Qt, another desktop/GUI application framework compiled into native views. However, in order to do this, NodeGui needs to make some tweaks to your application code so that it can convert your components into Qt components. While I believe this will certainly work, I'm not interested in this solution as I want to use exactly what I know without any tweaking to my Svelte files. By contrast, Tauri achieves its savings by wrapping the operating system's native webviewer—for example, Cocoa/WebKit on macOS, gtk-webkit2 on Linux, and Webkit on Edge on Windows. Webviewers are actually browsers, and Tauri uses them because they already exist on your system, which means our applications can stay purely web development products.
With these savings, the smallest Tauri app is less than 4MB and the average app weight is less than 20MB. In my tests, the smallest NodeGui application weighs about 16MB. The smallest Electron application can easily reach 120MB.
Needless to say, I chose Tauri. By following the Tauri integration guide, I added the @tauri-apps/cli package in devDependencies and initialized the project:
<code>yarn add --dev @tauri-apps/cli yarn tauri init</code>
This creates a src-tauri directory next to the src directory (where the Svelte application is located). This is where all Tauri-specific files are located, which is great for the organization.
I've never built a Tauri app before, but after looking at its configuration documentation, I'm able to keep most of the default values - except for items like package.productName and windows.title values, of course. Actually the only change I need to make is the build configuration, which has to be aligned with SvelteKit for development and output information:
<code>// src-tauri/tauri.conf.json { "package": { "version": "0.0.0", "productName": "Workers KV" }, "build": { "distDir": "../build", "devPath": "http://localhost:3000", "beforeDevCommand": "yarn svelte-kit dev", "beforeBuildCommand": "yarn svelte-kit build" }, // ... }</code>
distDir is related to the location of the built production-ready asset. This value is parsed from the tauri.conf.json file location, so it has the ../ prefix.
devPath is the URL that is proxyed during development. By default, SvelteKit generates a development server (configurable) on port 3000. I've been accessing the localhost:3000 address in the browser during the first phase, so this is no different.
Finally, Tauri has its own dev and build commands. To avoid the hassle of handling multiple commands or building scripts, Tauri provides the beforeDevCommand and beforeBuildCommand hooks, allowing you to run any command before the tauri command is run. This is a subtle but powerful convenience!
The SvelteKit CLI can be accessed through the svelte-kit binary name. For example, writing yarn svelte-kit build will tell yarn to get its local svelte-kit binary (installed via devDependency) and then tell SvelteKit to run its build command.
With this, my root-level package.json contains the following script:
<code>{ "private": true, "type": "module", "scripts": { "dev": "tauri dev", "build": "tauri build", "prebuild": "premove build", "preview": "svelte-kit preview", "tauri": "tauri" }, // ... "devDependencies": { "@sveltejs/adapter-static": "1.0.0-next.9", "@sveltejs/kit": "1.0.0-next.109", "@tauri-apps/api": "1.0.0-beta.1", "@tauri-apps/cli": "1.0.0-beta.2", "premove": "3.0.1", "svelte": "3.38.2", "svelte-preprocess": "4.7.3", "tslib": "2.2.0", "typescript": "4.2.4" } }</code>
After integration, my production command is still yarn build, which calls tauri build to actually bundle the desktop application, but only after the yarn svelte-kit build completes successfully (via the beforeBuildCommand option). My development command is still yarn dev, which runs tauri dev and yarn svelte-kit dev commands in parallel. The development workflow is entirely inside the Tauri application and now it is proxying localhost:3000, allowing me to still get the benefits of the HMR development server.
Important: Tauri is still in beta at the time of writing. That said, it feels very stable and well-planned. I have no association with the project, but it looks like Tauri 1.0 will probably be going to a stable version soon. I found Tauri Discord very active and helpful, including replies from Tauri maintainers! They even answered some of my Rust newbie questions throughout the process. :)
At this point, it was Wednesday afternoon of the fast winning week and I honestly started to feel nervous about finishing it before the Friday team demo. Why? Since I've been spending half of the week, even if I have a good-looking SPA in a runnable desktop application, it still does nothing . I've been looking at the same fake data all week.
You might think that because I have access to the webview, I can use fetch() to make some authenticated REST API calls for the Workers KV data I want and dump it all into a localStorage or IndexedDB table... you're totally correct! However, this is not my idea for desktop application use cases.
It's totally feasible to save all your data to some kind of in-browser storage, but it saves it locally to your machine . This means that if your team members try to do the same, everyone must get and save all the data on their own machine . Ideally, this Workers KV application should have the option to connect to and synchronize with an external database. This way, when working in a team setup, everyone can adjust to the same database cache to save time — and some money. This starts to become important when dealing with millions of keys, which, as mentioned earlier, is not uncommon when using Workers KV.
After thinking about it for a while, I decided to use Redis as my backend storage as it is also a key value store. This is great because Redis already treats the keys as first-class citizens and provides the sorting and filtering behavior I want (that is, I can pass the work instead of implementing it myself!). Then, of course, Redis is easy to install and run locally or in containers, and if someone chooses to go that route, there are a lot of hosted Redis as a service providers.
But, how do I connect to it? My app is basically a browser tag running Svelte, right? Yes – but it’s also much more than that.
As you can see, part of the reason Electron's success is that, yes, it guarantees that web applications render well on every operating system, but it also brings the Node.js runtime. As a web developer, it's much like including a backend API directly in my client. Basically, the "...but it runs on my machine" problem goes away because all users (unconsciously) are running the exact same localhost settings. Through the Node.js layer, you can interact with the file system, run the server on multiple ports, or include a bunch of node_modules to-I'm just talking casually here-connecting to the Redis instance. Powerful stuff.
We won't lose this superpower because we are using Tauri! It's the same, but slightly different.
Instead of including the Node.js runtime, the Tauri application is built using Rust, a low-level system language. This is how Tauri itself interacts with the operating system and "borrows" its native webviewer. All Tauri toolkits are compiled (via Rust), which keeps built applications small and efficient. However, this also means that we , application developers, can include any other crate (the "npm module" equivalent) into the built application. Of course, there is also a well-named redis crate that acts as a Redis client driver that allows Workers KV GUI to connect to any Redis instance.
In Rust, the Cargo.toml file is similar to our package.json file. This is where dependencies and metadata are defined. In the Tauri setup, it is located in src-tauri/Cargo.toml because again, everything related to Tauri is located in this directory. Cargo also has the concept of "functional flags" defined at the dependency level. (The closest analogy I can think of is using npm to access the internal structure of a module or import named submodules, although it is still not exactly the same, because in Rust, the feature flags affect how the package is built.)
<code># src-tauri/Cargo.toml [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.0.0-beta.1", features = ["api-all", "menu"] } redis = { version = "0.20", features = ["tokio-native-tls-comp"] }</code>
The above defines the redis crate as a dependency and selects the "tokio-native-tls-comp" function, which the documentation says is necessary for TLS support.
OK, so I finally have everything I need. I have to have my Svelte talk to my Redis before Wednesday ends. After looking around, I noticed that all the important things seem to happen in the src-tauri/main.rs file. I wrote down the #[command] macro and I know I've seen it before in the Tauri example of the day, so I 've learned to copy the various parts of the sample file to see which bugs appear and go away according to the Rust compiler.
Eventually, the Tauri app was able to run again, and I learned that the #[command] macro somehow wrapped the underlying functions so that it can receive the "context" values (if you choose to use them) and receive the pre-parsed parameter values. Additionally, as a language, Rust performs a lot of type conversions. For example:
<code>use tauri::{command}; #[command] fn greet(name: String, age: u8) { println!("Hello {}, {} year-old human!", name, age); }</code>
This creates a greet command, and when run, expects two parameters: name and age. When defined, the name value is a string value, and the age is the u8 data type - that is, an integer. However, if both are missing, Tauri throws an error because the command definition doesn't state that anything can be optional.
In order to actually connect the Tauri command to the application, it must be defined as part of the tauri::Builder combination, located within the main function.
<code>use tauri::{command}; #[command] fn greet(name: String, age: u8) { println!("Hello {}, {} year-old human!", name, age); } fn main() { // start composing a new Builder chain tauri::Builder::default() // assign our generated "handler" to the chain .invoke_handler( // piece together application logic tauri::generate_handler![ greet, // attach the command ] ) // start/initialize the application .run( // put it all together tauri::generate_context!() ) // print<message> if error while running .expect("error while running tauri application"); }</message></code>
The Tauri application compiles and knows that it has a "greet" command. It is also controlling the webview (we have already discussed it), but when doing so , it acts as a bridge between the current end (webview content) and the backend, which consists of the Tauri API and any other code we write (like greet commands). Tauri allows us to send messages between the bridge so that the two worlds can communicate with each other.
The front-end can access this "bridge" by importing functionality from any (already included) @tauri-apps package, or by relying on a window.TAURI global variable (which can be used for the entire client application) . Specifically, we are interested in the invoke command, which accepts the command name and a set of parameters. If there are any arguments, it must be defined as an object where the key matches the parameter name that our Rust function expects.
In the Svelte layer, this means we can do the following to call the greet command defined in the Rust layer:
<code>function onclick() { __TAURI__.invoke('greet', { name: 'Alice', age: 32 }); } Click Me</code>
When this button is clicked, our terminal window (where the tauri dev command runs) will print:
<code>Hello Alice, 32 year-old human!</code>
Again, this happens because the println! function (actually Rust's console.log) is used by the greet command. It appears in the console window of the terminal—not in the browser console—because this code is still running on the Rust/system side.
It is also possible to send some content to the client from the Tauri command, so let's quickly change the greet:
<code>use tauri::{command}; #[command] fn greet(name: String, age: u8) { // implicit return, because no semicolon! format!("Hello {}, {} year-old human!", name, age) } // OR #[command] fn greet(name: String, age: u8) { // explicit `return` statement, must have semicolon return format!("Hello {}, {} year-old human!", name, age); }</code>
Realizing that I'm going to call invoke multiple times and being a little lazy, I extracted a lightweight client assistant to integrate these:
<code>// @types/global.d.ts ///<reference types="@sveltejs/kit"></reference> type Dict<t> = Record<string t=""> ; declare const __TAURI__: { invoke: typeof import('@tauri-apps/api/tauri').invoke; } // src/lib/tauri.ts export function dispatch(command: string, args: Dict<string> ) { return __TAURI__.invoke(command, args); }</string></string></t></code>
Then refactor the previous Greeter.svelte to:
<code>import { dispatch } from '$lib/tauri'; async function onclick() { let output = await dispatch('greet', { name: 'Alice', age: 32 }); console.log('~>', output); //=> "~> Hello Alice, 32 year-old human!" } Click Me</code>
marvelous! So it's Thursday and I haven't written any Redis code yet, but at least I know how to connect the two halves of the brain of the application together. It's time to comb through the client code and replace all the TODOs in the event handler and connect them to the actual content.
I'm going to omit the details here because from here it's very application-specific - and mainly about the story of the Rust compiler bringing me a blow. In addition, exploring details is exactly why the project is open source!
At a high level, once a Redis connection is established with the given details, the SYNC button can be accessed in the /viewer route. When this button is clicked (and only then - because of the cost), a JavaScript function is called, which is responsible for connecting to the Cloudflare REST API and dispatching the "redis_set" command for each key. This redis_set command is defined in the Rust layer—as are all Redis-based commands—and is responsible for actually writing key-value pairs to Redis.
Reading data from Redis is a very similar process, just the other way around. For example, when /viewer starts, all keys should be listed and ready. In Svelte terminology, this means I need to schedule the Tauri command when the /viewer component is installed. This happens almost verbatim here. Additionally, clicking the key name in the sidebar displays more "details" about the key, including its expiration time (if any), its metadata (if any), and its actual value (if known). To optimize cost and network load, we decided that we should only get the value of the key as needed. This introduces the REFRESH button, which when clicked, interacts with the REST API again and then schedules a command so that the Redis client can update the key individually.
I'm not trying to end up hastily, but once you see a successful interaction between JavaScript and Rust code, you see all the interactions! The rest of my Thursday and Friday mornings just define new requests-reply pairs, which feels a lot like sending yourself PING and PONG messages.
For me - I think so for many other JavaScript developers - the challenge this week is learning Rust. I believe you have heard this before, and you will definitely hear it again in the future. The ownership rules, borrow checks, and the meaning of single-character syntax markers (those markers are not easy to search by the way) are just a few obstacles I have encountered. Thanks again Tauri Discord for his help and kindness!
This also means that using Tauri is not a challenge – it is a huge relief. I'm sure planning to use Tauri again in the future, especially if I know I can just use webviewer if I want to. Digging into and/or adding Rust sections is "extra material" and only needs it if my application requires it.
For those who want to know, because I can't find another place to mention it: On macOS, the Workers KV GUI app weighs less than 13 MB. I'm very excited about this result!
Of course, SvelteKit also makes this schedule possible. Not only did it save me half a day of configuring the tool belt, but the instant HMR development server might also save me several hours of manually refreshing the browser - and then the Tauri viewer.
If you've seen it here-it's impressive! Thank you very much for your time and attention. As a reminder, the project is available on GitHub, and the latest precompiled binaries are always available through its publishing page.
The above is the detailed content of How I Built a Cross-Platform Desktop Application with Svelte, Redis, and Rust. For more information, please follow other related articles on the PHP Chinese website!