In this chapter, we're going to add preview to our project. We're going to be doing this using web containers and terminal. In order to implement web containers, we have to configure them using a specific package and by setting up specific course settings. We're also going to have to build a complete file tree mounting system because our database stores our files in one structure whereas WebContainer expects a whole different structure. We're going to implement the actual terminal using a package called Xterm.js.
And finally, we're going to create preview settings, allowing the users to add custom commands on how to run their project. Let's get started by installing the dependencies we need. Npm install at webcontainer forward slash API. Then the second package is xterm, xterm, and finally, xterm forward slash addon fit. So make sure you have these three packages installed.
Next thing we're going to do is set up the foundation on which everything else will depend on. And we're going to start by configuring cross-origin isolation headers. So how do I know that I have to do this? Well, very simply by following the web containers documentation. In here you can see that in order to configure the headers, I mean in order to make web containers work we have to configure the headers, right?
But they give us a couple of options. We can either use require corp or we can use credentialless. I have not researched this too much so I'm not really that familiar with the course and all of the headers but what they do know is that require corp will actually cause problems with some other features that we have in our app such as billing which will come in later. Because of that we're gonna be using credentialless. I just wanted to make that clear.
So the only reason I'm choosing credentialless is because this enables web containers to work within our project and it also doesn't break anything else in our app. So that's the only reason and I just don't want you to think that I'm pulling this information out of nowhere. There actually is a reason why I'm choosing one over the other. So let's get started by going inside of next.config.ts but of course just before that I always love to show you my package.json so you can see my packages, Web Container API version 1.6.1 X term add on fit 11.0 X term X term 6.0.0. So let's go inside of next dot config dot DS.
So far, we only have an empty next config and sentry configuration if you have set it up. If you didn't then you don't have this. But what we need right now is this part right here. So in here we have to add specific headers. Basically we have to add these two to our next config.
Now the way you do that inside of next config is by using asynchronous headers. Inside of here you have to return an array of objects. So let's go ahead and return an array of objects let's go ahead and select the following source basically every path in our project and then we have to add an array of headers the headers will be objects basically defining these two inside So the first one will have a key of cross-origin embedder policy, and the second one will be the value, which is credentialless. And then we're gonna go ahead and do the other one which is cross origin opener policy. So make sure you have that key and value same origin.
So basically we have transferred the rule that we need right here into proper next headers. To check if you've done it correctly, you can go ahead and npm run dev your app and you shouldn't see any errors in your app. You should just be able to run localhost 3000 normally with no errors. Great. Now that we've got that ready, let's go ahead and update our convex schema.
So you know, we need to update our convex schema because of this to create preview settings with custom commands to allow users to clearly define how to run their app because your app can be running on port 3000, 3005, 5000 depending if you're using Create React app or Vite or Nuxt or Next.js or a bunch of other apps, right? So that's why we need to allow our users to specify themselves if the AI cannot figure out how to run the app itself. So I'm gonna go ahead and find the projects table. Here it is. And after export the repo URL, I'm going to extend it with another field called settings.
So settings is going to be completely optional. And it's going to be an object, the object will require, I mean, it won't require anything, basically everything here is optional. But you will be able to pass install command and dev command. Basically, we're going to instruct the web container on how to install the packages in the project and we're going to instruct WebContainer on how to run the developer script so we can actually see it. So now that we've added this let's go ahead and save and let's make sure that we have npx-convex-dev running.
This will synchronize our new schema And since this is a completely optional new field, we won't have to delete any of the previous projects. Great. Now let's go ahead and implement a mutation that will allow users to update the settings. So head inside of convex-projects.ds and in here I'm going to go ahead and I will prepare the update settings mutation. So let me go ahead and close this.
There we go. The update settings is a mutation which accepts ID and the actual settings object for the arguments. Just make sure that the install command and dev command aren't accidentally misspelled. They need to match exactly the settings object above. Great.
Now first things first, let's go ahead and verify our identity. So we've already done this a couple of times, so we can just copy it from here, like so. Now, once we have the identity, let's go ahead and get the project. We can get the project using arguments.id since the ID in the arguments is referring to the project. If we don't have a project let's go ahead and throw an error project not found and we also have to check if we actually own this project so if project owner ID is not identical to identity subject we are not authorized to update this project we shouldn't allow anyone else to change the developer and install script of this project.
And finally, let's patch the projects table using the project ID and the new settings we have. And let's also tweak the updated ads since it's just been renewed. Perfect. The next thing we have to do is we have to build the file tree utility. So that's referring to this part, basically.
Right now, let's take a look at how in our schema, here it is, it's open. We store our files. So here they are, files. So when you load files for a project, you basically get an array of objects. And each object has a project ID, parent ID, name, type, content, storage ID, and updated app.
So it's just a very flat array. This is how it looks like basically. An array and then you have an ID 1 to 3 type is probably file name is foo.js and then inside you have what is it content which can be something like console log hello world right And so on and so on with a bunch of objects and even folders aren't really nested. Right. You just have parent ID one to three.
So for example this file would be within this one Makes no sense right now because then this has to be a folder and it shouldn't have any content but I think you get the point. Basically we have a very flat structure which works for us and for our file tree. That's why we've developed it this way but it's not exactly compatible with web containers right because if you go ahead inside of the web containers documentation here you will find that their file system expects this, right? So they have a very different kind of structure here. You can see how they do folders, right?
We basically have to create a util which will convert our flat array structure into this. And I'm gonna show you how this function looks like and honestly I think it might be better if you just went to my source code and copied this function and you will see why. We're going to attempt to build it together but it's very, very complicated. I think it might be of use to you to just visit the source code anyway simply because it's a very complex function. So we're going to go ahead inside of features and in here I'm gonna create a new folder called preview and inside of preview I'm going to create a new folder called utils and finally inside file-tree.ts And the first thing I'm going to do is I'm going to import from my new web containers API, the type that they expect, which is the file system tree.
And the reason we are importing this is because that's going to guide us into confirming that we've developed the proper function which matches this cast at the end. So let's import document and ID from convex generated data model. Let's go ahead and define our file document by using document files. So now you can see exactly how our file looks like. And using this type safety, what we're going to do now is very simply convert flat convex files into nested file system tree for web container.
So we're now converting to this right here. And it's basically just a very boring recursive function you're gonna see. So let's export const buildTreeFile() and we accept files. Now those files are a type of array of file document and what we expect to return from this function is the file system tree which we have imported above. That's the goal.
Right now, we have an error because we are obviously not returning that. So we have to start by defining a tree, which is an object with the type of file system tree. Then, in order to remove any duplicates and for easier manipulation of these files, let's go ahead and put them inside of a map. So files map, new map, and inside for each file go ahead and return an array of file.id and then the rest of the file content. Now the first thing we're going to develop is getPath function.
So getPath function will accept a file which is a file doc type and it will return an array of strings. This will allow us to traverse through their parent ID to get the full path of the project. So something like components, I don't know, navbar, icon.tsx. That's what we kind of plan on returning here. So we have to start by getting the file name.
So initially, the parts of the path will start with just the file name. Because that's the one thing we know, right? For example, navbar.tsx. This is what we know about every file. So that's the beginning of the array.
And we also know the parent ID. So let's assign file.parentID here as well. And now we're simply going to traverse up the path. So while parent ID is available, let's go ahead and get the parent using files map dot get parent id so this is why putting them in a map is useful because we can very easily just get the exact parent of the file whose path we are just trying to find In case there is no parent we can just break the method. Otherwise let's go ahead and unshift into our array parent.name and then let's assign the parent id to be its parent.parentId and finally return parts.
So basically in the first iteration of this loop, we're gonna go ahead and start with something like icon.tsx. Then this icon.tsx file will have a parent and then in the second iteration we might have something like components forward slash icon dot dsx. If components has a parent we might have something like source components icon dot dsx. That's what we're doing right now. So we have So we are trying to generate a path similar like that.
Alright. Now what we have to do is for each file of files which we have in the param that we've passed here, We have to generate the path parts using our get path. So passing the file here assign the current to be tree, which we also have. Let me just find here, right? So this is the tree in this stage of iteration.
So basically we're just going to be adding files to an object until it looks like this. So let current is a tree. Let's go ahead and open a for loop within a for loop. Let iterator be 0. Iterator is smaller than path parts dot length and iterator is increasing.
And now in here we're gonna go ahead and change the part to be whatever is the current iterator from path parts and we're also going to check if that part is last so if iterator is equal to total amount of path parts in case if it's last we also have to check if file.type is folder. Now if it is, we have to display that in this type of structure. Now in order to do that, this is how we do it. We're going to add to the current object for that specific part a very simple indicator of an empty directory like so else if not file dot storage ID and file dot content is not undefined So, else, this is a case for last file and if it's a folder. This is a case for text files.
We are purposely skipping storage ID or binary files because they just increase the complexity of this by 100. We might revisit this later, but right now we're not even working with storage, with binary files, So it's fine. So let's just go ahead and make sure that we can add normal content here. So that would be again, current part, open an object, file, contents, file.content. Because usually For storage ID, we would have to load the storage ID and then load base64 content inside of it.
So really a lot of complexity for something that we don't even have yet. Now that's the case for isLast. If it's not last, we're going to go ahead and do something else. So let's check if we don't already have that inside of our current tree. So if we don't have that part let's go ahead and simply add that part like so with an empty directory and then let's go ahead and do const node current path my apologies not path part if there is directory in node current is node dot directory It's very easy to get lost in this function.
It's really not a simple one, right? But it is what we have to do. And make sure to return the tree at the end. Okay. So you shouldn't have any errors here.
You should now successfully accept flat convex files and it should return this. My project directory foo.js file and then the contents inside or empty folder just directory. So that's what we were doing right here, right? Feel free to open the source code for this, you know, this is super complicated. Even I use the AI assistance on this, I get very easily lost in like these kinds of recursive functions.
Actually, I don't think this is recursive at all, but I think you get what I'm saying. Okay, now what we have to do is we have to create a simpler method which will help us get a full path for a file by traversing the parent chain. Very, very similar to what we did here, except this doesn't actually create the full path this just returns an array of strings right so what this does is it accepts an input for example let me show you it accepts an object ID 123 content console log like this name foo.js and it also probably has a parent ID of 321, right? And it returns an array of something like source components foo.js, right? That's what this getPath function does.
But what we're gonna develop now at the bottom is actual source components for the JS in like a breadcrumb type of string. So nothing we haven't done already, right? So let's go ahead and define a function. It will be called getFilePath. And the function itself will accept two params, the actual file that we're trying to traverse through and the files map, basically a map of ID of files and file doc.
Let's go ahead and very simply define the parts, starting with file name exactly as above. Let's go ahead and define the parent ID to be file.parentID. And again, while the parent ID is active, let's go ahead and traverse up the chain by getting the current parent if there is no parent let's break but if there is let's go ahead and add it to the array at the start of the array, that's what unshift does at the start of the array and let's assign the new parent ID to that file's parent ID and the only difference we're gonna do is instead of returning parts like we did here is parts.join So why did I just duplicate the function again? Well, because we are going to use this independently of this one. Basically, we are going to repeat this many times.
It's a very useful function. All right, so again, feel free to just open the source code if you think there's a bug here and you can just copy it and then it will work right away. But I'll kind of try to explain what we're doing here. Basically, we have our file structure and we need to convert it to this file structure. Great.
Now let's go ahead and implement the WebContainer hook. The WebContainer hook is what will actually start the WebContainer, use these functions which we've just developed and allow us to, well, see something, right? Do we need a hook for that? Well, no, but since we are in React and XJS, we work with hooks and I kind of feel like this is a natural way of using web containers. So let's go inside of features, preview, and in here I'm gonna create hooks.
And in here let's add use-webcontainer.ts. Perfect. Let's start with useClient. Actually, No need to start with useClient because we are only going to use this within a client. Instead, let's go ahead and start with all the imports.
So, make sure you have useCallback, useEffect, useRef and useState from React, useQuery from convex React, and webContainer from webContainer API. Now, let's go ahead and reference our build file tree and get file path features. And it looks like I have forgotten to export one of these. So let me quickly go back here, utils file tree. I'm exporting a build tree file and I wanted to name it build file tree.
There we go. So that resolves the problem. Okay. Then let's also import the usual suspects API from generated API from convex and ID from generated data model. What we're building now is a singleton web container instance.
So we're going to need to have a let web container instance, which is a type of web container or now, and boot promise, which is a promise of web container or now, right now, they can only be null. So this is throwing errors. But later we will change it to have this other type so it won't throw any errors anymore. We're going to start by defining a function called getWebContainer. This will return a promise and web container inside.
Like so. So first things first. If we already have a web container instance, we're going to return a web container instance. This way we don't have two instances. Then if we have a boot promise, if we don't have boot promise, my apologies, we're going to start a new boot promise using webcontainer.boot and what this is referring to is our cross-origin policy.
And if you remember, we've set it to credentialless. So instead of next.config.ts, we've set the value to be credentialless. And basically, because of the instructions here in the documentation, let me go ahead and show you, configuring headers, If you switch to credentialless, you also have to boot your web container specifying this key right here to be credentialless. So that's where I got that from. Alright, and if you're wondering about exact documentation for this, it's also here, but it will not exactly show you how to do it like I'm doing it.
You can see how to create a WebContainer instance using WebContainer.boot but this is more like an overall guide on how you would quick start it. What we're doing is basically a compilation of a bunch of these references and ways of booting it into a hook. That's kind of the complicated part. But feel free to look through Quick Start so you can actually see some of these functions that I will be calling here. All right.
So so far, we are making sure that we are not able to boot the WebContainer instance twice. That's why we're checking if there is no boot promise, only then assign a WebContainer boot with credentialless headers. Then let's go ahead and set the WebContainer instance to be await boot promise. And finally, return WebContainer instance. And just like that, we've resolved those two errors right here because now in the runtime, we actually assign them to their types.
Great. So that's it for getWebContainer. Now what we have to do is develop a very simple method to tear down the WebContainer so we don't have any memory leaks. So we're going to tear down the web container if we have the web container instance. So if the web container instance already exists, let's do webcontainerinstance.teardown.
And let's do webcontainerinstance and assign it back to null. Let me just fix the indentation here and outside of the if clause set the boot promise to be null. There we go. So now we have Teardown WebContainer and GetWebContainer functions ready. What we have to do now is we have to develop an interface for our hook.
So our interface use web container props will accept project ID which is a type of ID complex projects, enabled which is a boolean and optional settings which we define in our database. Remember, install command and dev command. If you're unsure, you can always open your schema.ts file and go inside of your project's table and find the settings. Make sure you have install command and dev command. And don't mistype them here.
Great. Now we have the interface for our hook, which means we are ready to start building the actual hook. So useWebContainer hook uses the same named props here, accepts project ID enabled and settings. The first thing we're going to do is we're going to set the status of this web container. So stages, set status from new state.
It can either be idle, booting, installing, running or error. And by default, it's going to be idle. And while we are here let's also define all other states which we are going to need starting with the preview URL which can be a string or null and by default it's going to be null. Then let's go ahead and add the error which can again be string or null. The restart key will very simply be used to change the key of an element.
Changing the key of an element in React makes it re-render entirely. So we're gonna use this as kind of a hack to refresh the web container so in case it gets stuck or it boots incorrectly, the user can always forcefully restart it so it installs the dependencies again. Then let's go ahead and also prepare the state for the terminal output, which can be a string, and well, when you define the default type and it can only be that type, you don't have to define it the same way we didn't have to define number here, right? Only when it can be multiple types does it make sense to define it like string and null. Great!
Now let's go ahead and prepare some references here. So we're going to need two references. The container ref which is useRef and it can be either a WebContainer type or null. And by default, it's going to be null. And hasStartedRef, which we are very simply going to be used to prevent some duplication.
So by default, this one will be false. Great. What we have to do now is we have to fetch files from Convex. And this is where Convex real-time actually comes in so handy. Convex auto-updates on any changes which means we accidentally developed hot reload just by using convex.
So if I want to get my files, all I have to do is call useQuery from convex and call api.files.getFiles and pass in the project ID from the files which I need. Or let me check even further, maybe I even have a hook for that instead of use conversation. Use file, actually this will be in the projects I believe. Hooks, use files, Do I have that? Looks like I don't have Use Files.
So let's go ahead and quickly create Use Files here. Use files and all it accepts is a project ID. And I'm not sure if it should be able to be skipped. So this will be an ID of projects, get files, either pass in the project ID or skip it. So just like that we've developed an abstraction use files.
So now in here I can just call use files and pass in the project ID there we go use files from features projects hooks use files and I'm gonna move it here and I'm gonna remove the import of useQuery from convex React. I think this will work just fine. We're gonna see later if it doesn't by chance. So what we ought to do now is boot the actual web container. So initial boot and mount using a useEffect.
Let's prepare an empty useEffect like so. And now in here we're first going to check if we should prevent this from happening. So if we are not enabling this or if there are no files. Or if files.length is 0 and remove the exclamation point here. Or if hasStartedRef.current is true.
If any of that happens, we have to return early and not do anything. It either means we didn't enable it, there are no files to load, or we have already started and this is an accidental reboot. So we're immediately going to change this to true then so now it makes sense if this happens twice this will prevent that from happening. Now let's go ahead and develop the actual start method which is going to be an asynchronous function. Let's go ahead and open a very simple try and catch block here.
Instead of try, let's go ahead and set the status to be booting. Then set the error to null. Then set terminal output to be an empty string. This is kind of like a reset. And now we have to create a function to append the output to terminal.
So appendOutput accepts data, which is a string, and it returns setTerminalOutput, and it will simply append the data to the current value of the state. Like so. Then let's go ahead and actually get the web container and assign it to a ref. So container is await getWebContainer, a function we've developed first here. And then we simply assign that to a reference.
Then we have to build the file tree so we can actually mount the files to a web container. So file tree buildFileTree files. And let's go ahead and mount that using await container dot mount file tree you can see that if we didn't build the build file tree function these files from convex which we load here would be completely incompatible, right? You can see it's an internal incorrect structure. That's why we have to build the file tree first.
Then let's go ahead and look for an event called container on server-ready, skip the port, only focus on the URL, and set the preview URL to that URL we've received from an event server-ready, and change the status of this hook to running. At this moment, we can also set the status outside of this event, right, to installing, because this won't go before this one. Only once we receive server ready will it change the running. So ignore the fact that we are defining this before we define this. Okay, after we set this to installing we actually have to parse the install command which by default is going to be npm install.
So how do we define the install command? Well, very simply, we can use the settings, right? Let me go ahead and find where we define the settings, just a second. So we pass the settings, right? We could technically also fetch the settings.
That might also work, but then we'd also have to restart the project carefully so I'm going to use it as a prop for now. So it's gonna be settings.install command or npm install like so And then go ahead and use install command dot split. Split it basically by space because The way web containers accept install commands are in a very specific way. So what we have to do is basically separate this array, install bin, and then the rest of the install arguments. Like so.
So we're basically going to have an array npm and install or npm run install, whatever the user specifies. And then let's go ahead and append output to a terminal. So the terminal shows the user exactly what we're doing right now. So the append output will have install command and a line break. And now we actually have to spawn this command.
So all of this right now is just cosmetics. And now we're initializing install process with await container.spawn and passing the install bin command followed by the rest of the arguments inside of the command. Great! Now Let's go ahead and create a writable stream from the install process. So installProcess.output.pipe2, execute that, and call newWritableStream.
Open an object inside, define the write function, which has data as a prop, and very simply use the append output and pass in the data here. Like so. And it's not writeStream, it's writeableStream. How do you write this? WriteableStream.
There we go. So I believe that I might have imported writeStream from fs, so if you have done that, you can remove it. Okay. So it's a writable stream. Okay.
And this will basically display the entire output of the install process into the terminal. Now let's go ahead and also catch the install exit code using await install process dot exit. If install exit code is not zero, it probably means there is an error. So let's go ahead and throw an error. Throw new error and inside we can just add a template literal showing the command we attempted to run failed with code and then show the code which was thrown because the exit code can be zero which basically means okay I've successfully finished npm install or it can be something else so we just show that back to the user great What we have to do now is we have to parse the developer command.
The developer command is basically npm run dev, something to start the project. And we're going to go ahead and use the same logic, right? So developer command is parsed through settings.dev command. Keep in mind that both settings and dev command can be optional, so we have to add a fallback, npm run dev. So we're going to assume most of the projects will be run with npm install and npm run dev, but of course, users will be able to define their own.
And then we also have to split the dev bin and the dev arguments using .split with an empty space. Let's go ahead and immediately append output to the terminal with a new line breaks and the developer command. Let's go ahead and initialize the actual dev process by spawning this. So developer process await container.spawn, developer bin and the rest of the developer arguments. And now we just have to do another writable stream here.
So developer process that output pipe to new writable stream called the write function which accepts the data and simply appends the output to the terminal of that data. So everything that's happening during the spawning of this command will be shown to the user in their terminal simulating the exact experience you would in a real terminal. Alright. And in the catch method here let's go ahead and do catchError setError if error is instance of error doError.message otherwise unknown error like so and set the status of the entire hook to error so for example when we throw from the install code that will be called right here and set the status to error. Perfect.
And now execute the start method like so. So we've just defined the entire start method, right? But we never called it. So make sure that at the end, you actually call it. And now for the dependency array, there are a couple of those we have to add.
So let's add enabled, let's add files, let's add restart key, let's add settings, ?.install command, Well, dev command, install command, both of them are needed basically. Great! Now, let's go ahead and implement a simple hook to enhance the hot reload of the files. So, sync files, File, Changes, Hot Reload. This will be another use effect, though much simpler.
So, let's open an empty use effect once again. Let's go ahead and check if we have the container from our ref. If there is no container or if the status is incorrect or if there are no files, basically if any of these cases happen, let's do an early return. What we ought to do then is create a simple file map. So file map, new, map, files.map, get the individual file and add them in an array showing the file ID and the file content as the other part in the array.
Great. Now let's go ahead and open a simple for const file of files. Let's go ahead and check if the file is an actual type of file and it has content and it's not a binary file. We can do that by adding an if clause. If file.type is not a file or if file has a storage ID or if file has no content at all just continue no need to do anything but if the file is an actual file which has text content let's go ahead and define the file path using get file path util passing the file and the file is mapped.
And then we can go ahead and write that file to the container. Container.fs writeFile filePath file.content. In the dependency array add files and status. There we go. Let's go ahead and reset the entire thing if we receive a disabled event.
So this is a much simpler use effect. Here it is. So if we reset the enable prop, so if not enabled, immediately change has started ref.current to false, set the status to idle, set preview URL to null, and set error to null, basically a complete reset. And then let's go ahead and just implement a function to restart the entire web container process. So we're just doing some teardowns now, right?
So restart is going to be a use callback. And the first thing it's going to do is going to call a function TeardownWebContainer. After that, it will set the container ref.current to null. It will reset hasStartedRef.current to false. It will set the status to idle.
Set the preview URL to null. Set the error to null. And finally, we're going to forcefully increase the key which will again just reset everything even more. Alright, and finally let's return status, preview URL, error, restart and terminal output. That's it.
That's our complete hook. We are now ready to develop the actual terminal component. Our next task is to implement the terminal component. Fun fact, the library which we're using, Xterm.js, is actually used in real VS Code and various other projects. So let's go ahead and see how we can implement a terminal component using the package we installed, Xterm.js.
So I'm going to go inside of features, preview, and I'm going to go inside of... Let me see, I have to create a new folder called components. And I will create preview-terminal.dsx. I'm going to start by marking it as use clients and then I'm going to import useEffect and useRef from react followed by extern packages, basically the terminal and fit addon package. The fit addon package will be used because the terminal will be within an allotment pane which can be resized so because of that we need that package otherwise you can develop it without this package.
And we also need to import the CSS for the extern. Let's start by creating an interface preview terminal props, which very simply accepts the output. So in order to actually develop the component, we need to export preview terminal and define the output here. Alright. Now instead of the preview terminal, let's add a few refs.
We're gonna have a container ref which can be HTML dev element or null. We're going to add a terminal ref, fit add-on ref and last length ref. All of this. Then let's go ahead and create a use effect which will initialize the terminal. So I'm gonna go ahead and open an empty use effect like we usually do.
And I will first check if we are ready to run the terminal. So if there is no container ref or if there is no terminal ref, let's do an early return. Otherwise, let's go ahead and initialize a new terminal. Now inside of these options here, you can add a few settings. Now I will configure it the way I prefer it and the way I found it looks the best for our project.
You can of course tweak this later on. So I'm going to enable this setting. I'm going to enable this setting. I will set the font size to 12 and Lastly I'm gonna add font family and a background color So font family will be monospace and theme will use this specific background Now outside of this terminal constant, let's go ahead and define a new fit add-on plugin. So this fit add-on plugin will very simply be loaded into a terminal using their built-in load add-on function.
And after that we are ready to open the terminal in the container which we store in ref. And then let's simply go ahead and add both of those, fit addon and terminal to their refs. So So terminalref.current gets the terminal and fitaddon.ref gets fitaddon. We now have to write the existing output the moment we mount the terminal. So if there is any output use terminal.write and pass in the output, and also change the lastLengthRef.current to be output.length.
Then we're going to add a request animation frame function callback and call fitaddon.fit every time a new animation frame is rendered. This way we can have a terminal which expands within our resizable panels. Let's also add a resizable observer. So resizeObserver calls new resizeObserver and also calls fitAddon.fit. And we actually have to observe something, so let's observe the containerRef.current using the resizeObserver.observe function.
The last thing we ought to do is a cleanup function in this hook. So in the cleanup function we're going to disconnect the resizeObserver, we're going to dispose of the terminal and we're going to reset our refs. Then let's go ahead and add the output into the dependency array right here. Let me just see do we actually need... Well yes this is output is only used to write existing output.
It's not used to be updated So do not add it here. I'm gonna add a little comment here Output does not need to be a dependency since it is not intended to update anything just used on mount Intended. All right Then let's go ahead and create another use effect which will be used to write the received output so this is where we will use the output in the dependency array so let's go ahead and define this passing the output in here because now we will need the output so again we're going to check if we have no terminal ref.current or if the output.length is smaller than lastLengthRef.current. So if output.length is smaller than lastLengthRef.current, let's simply clear the terminal and reset this ref we are tracking back to zero. I found this helps with the resizable issue because there was some issues I was kind of fighting the terminal to work within resizable panels.
So this is one solution that I found helps to clear up some of the content. Then let's go ahead and define new data to be output.slice lastLengthRef.current. And if we have new data, let's go ahead and write it to the terminal. We can access the terminal instance using our ref. So if we have new data call terminalRef.current.write and pass in the new data.
And update the lastLengthRef.current to be output.length. There we go! One more thing we have to do is a very simple return method. It will return a one single div, a self-closing div, and this div will have a reference of container ref and then it will have a class name. Flex1, minimum height of 0, padding 3 and now we're going to have some very specific styles for the terminal.
So we're going to be using this a lot. So feel free to copy that. And the classes I'm adding now is basically just very specific styling of the terminal. So this is one class. Never mind that it's collapsed, see?
Besides heightFull, we're also gonna have xTermScreen to be heightFull, and backgroundColor will be sidebar. There we go. That is our preview terminal component. Great. Now that we have that, let's go ahead and implement the settings popover component which will allow us to change the install script and the dev command.
So I'm gonna go ahead back inside of features, preview, components, and in here I'm going to add preview-settings-popover.tsx This will mostly be a form, so let's go ahead and mark it as useClient, import Zod, and let's import everything else we're gonna need which is gonna be useState, useMutation, useForm, settings icon. We're going to need which is going to be use state, use mutation, use form, settings icon. Looks like we don't have 10 stack react form. So did we not build any forms before? We do have form itself, but we don't have this.
Basically, ChatCN added a new way to write forms. So you can now write forms either in the old way. Let me find form. Where is it? I cannot find form.
Maybe I should search for form. Oops, looks like something's not working on the website. All right. So if you scroll down and find forms, you will find React Hook Form, which is I believe How We've Built Forms So Far. Or No, this is the new one.
Okay. So they now have basically either you can use the react hook form or 10 stack form and 10 stack form is the new one. So I think it might be better to, you know, teach you how to use this one simply because I don't know. I mean, I've built the react hook for many times but I didn't build this one too much so I just want to find a way to install this because I can see that I have a missing 10 stack react form and I will save this file anyway and I'm just gonna go ahead and research a bit inside of my source components UI Do I have something called the field I do have so I think I should be able to You know run this normally. I'm just surprised that Shazian command didn't install 10 stack react form So I'm gonna check in my package JSON to confirm and we truly don't have 10 stack react form.
So what I'm going to do is I'm just going to install it. I think That's the only package we need. So npm install at 10 stack forward slash react form. Usually these kinds of things get installed by running this command, but looks like it somehow missed now. So let me see if I have it now.
Here it is and I'm using 1.27.7 version. Alright, so back to business. 10 stack react form. Besides these imports we're also going to need a button component. We're going to need all the imports from the popover component, which is popover, popover content and popover trigger.
We're going to need to import all the field components, field, field label and field description from components UI field and we're going to need to import the input from components UI input. Let's go ahead and import API and let's go ahead and import document and ID from generated data model. I'm going to start by defining the form schema which is an object which accepts install command and dev command. We've already seen this a few times. Then let's create an interface preview settings popover props which accepts the project id for the settings initial values if we already have some settings and an on save method.
Then let's go ahead and actually define and export a component. So preview settings popover uses the props, accepts project ID, initial values and on save. It will have its own open and set open use state. It will have a very simple method to update the settings you calling use mutation API projects update settings if you wish to you can always Abstract this Let me see. So this is for projects.
So this would be inside of projects, hooks, we have used projects. So yeah, you could do export const, let's see what we call it, use rename projects, so this would be use update project settings, like so. And it will just return this, and then later you can add optimistic mutation let me see do we have any to do for optimistic mutation we do not to do add Optimistic mutation if you want to improve it later, so I like to abstract them this way Especially because of those optimistic mutation things it's way easier to maintain that in a different file So that's what I'm gonna do. I'm going to import it like that I'm going to move it here separately and I'm going to remove the use mutation import since I no longer need it. I can directly access update settings from the hook now.
Great. Now what we ought to do is create the form. So we're going to start by calling the useForm hook from tanstack react form. So this is a different hook than your usual react hook form. We're going to define the default values which can be install command and developer command which come from the initial values prop.
If we have it we use it otherwise we fall back to an empty string. Now for the validators we're simply going to use onSubmit. Validate using form schema which we defined right here. After the validators, let's call the actual onSubmit method, which is gonna be asynchronous, accepts a value, my apology, yes, just a single value. And you can see how in TanStack React form, we define all those things instead of use form, whereas in the React hook form, you have to do it kind of all over the place.
I think I kind of prefer this one, to be honest. Now instead of this onSubmit, let's simply define what we do. Await, update settings, pass in the ID to be project ID and the actual settings, install command value.install command or undefined if we ever want to reset the values and developer command to be value dot developer command or undefined. There we go. After that we close the popover and then we call the onSave callback if we have it.
There we go. Now let's go ahead and define a very simple const handle open change method which will receive a new isOpen value which is a type of Boolean and in here what we're going to do is check if is open. If it is, call form.reset with an install command of the initial values, check if we have install command or fall back to an empty string and then do the same for dev command. And finally, outside of the if clause, simply call setOpen to isOpen. So every single time handleOpenChange triggers, If we actually open it, we're just going to reset the form.
Great. And now we just have to build a composition for the popover. So let's go ahead and return the actual popover element, which will accept an open prop and on open change which we've defined just now. Then we're gonna have a popover trigger which will have an as child prop so it will actually become the element inside. The element inside will be the button with the size of small, variant of ghost, class name height full and rounded none, and the title, previous settings.
And inside, we're simply going to render a settings icon, which we've imported from Lucid React with a size three. Then it's time to build the popover content which we composition outside of the popover trigger. It will have a class name of width 80 and an align of end. Inside we are working with a normal form element so a native HTML form element which has an on submit in which we prevent the default and simply handle submit from the form hook. So that form is referring to this form right here.
Then let's go ahead and add a div here so we can separate the fields. I'm going to add just one more div so we have further separation. An H4 which serves as a label, preview, settings. Let's give the heading for a class name of font medium and text small. And the paragraph configure how your project runs in the preview.
:02 Let me fix the typo. And let's give this a class name text extra small and text muted foreground. Then let's go ahead and develop our first field. So that's going to be outside of this right here. And we're going to add form dot field like so.
:24 And let's go ahead and give it a name which can be install command. And then we're going to render a field by extracting the field prop and using the field composition. So field label, let me go ahead and fix the typo here, field label will have a text of install command and HTML for field dot name so these are completely accessible fields and below the field label let's add an input with an idea of field name, name of field name, value, field.state.value, onBlur, field.handleBlur, onChange, event, field, handleChange, event, targetValue and a placeholder npm install indicating to the user what is a default value and a little description to explain to the user what this is and then we can repeat the entire thing so I'm going to paste out the entire thing here Here it is another form field this one for dev command. Again we extract the field, we render the field label with HTML4 field.name and we simply render start command inside. And then again another input with the exact same props with a different placeholder npm run dev and a slightly different description explaining that this is a Command to start the developer server.
:57 So these two are identical this one is for dev command and the one above is for the install command great and now let's go ahead and add one thing you probably didn't see before which is a form.subscribe and inside of here it can have a selector and it can look for can submit and is submitting and then using those fields we can go ahead and render something inside and what we are going to render is a button component which will have a type of submit, size of small, class name with full it will be disabled if we can't submit or if we are already submitting and if we are, we're gonna display saving, otherwise we're gonna display save changes. There we go. That's it for the preview settings popover and that's what it's like to work with 10 stack form. I personally prefer this over React hook form. And I'm gonna remove API from here.
:01 Not to say that React Full Form is an amazing library, it absolutely is. I just always feel like developer experience with TanStack is just a tiny bit more nicer. Great. Now let's go ahead and start bringing everything together and finally rendering this. So we're gonna go inside of Features, Projects, Components and I'm going to create Preview-View.tsx I'm gonna go ahead and mark this as use client.
:35 I'm going to import use state and allotment. And then I'm going to go ahead and import loader2 icon, terminal square icon, alert triangle icon and refresh CW icon. I'm going to import our use web container from features preview hooks use web container which we've developed. I'm going to import our preview settings popover from features preview components preview settings popover which we just finished. Then I'm gonna add the preview terminal from the exact same place.
:08 We've finished all of these components already. Then I'm gonna add a button component. I'm going to add a very simple use project from hooks use project and I'm going to add id from generated data model from convex. I'm going to export const preview view which very simply accepts project id which is a type of ID projects. In here, the first thing I'm going to do is I'm going to load the project using the hook project, use project, project ID.
:45 Then I'm going to decide whether I should show or shouldn't show the terminal using a simple state. Then we can go ahead and call useWebContainer finally. Now useWebContainer needs to have a couple of properties, such as the project ID, so we know what files to load and what files to transform into a specific file tree, whether it's enabled or not, and finally, the settings for the project, so the user can change the install command and the developer command. The web container gives us the following items status, preview URL, error, restart, and terminal output. Let's go ahead and show the loading state if the status of the web container is booting or installing.
:39 And now let's go ahead and start rendering the entire thing. So we're going to start by defining a div which will take the full height, initiate the flex system and a background. Then we're going to display a navbar with a height of 35, or as written here, 8.75. So I'm going to change it to that flex item center border bottom background sidebar and shrink zero after that I'm gonna go ahead and add a button allowing us to refresh the web container. This button will have a size of small, a variant of ghost, class name of hideFull and roundedNone.
:23 It will be disabled if the web container is loading. And on click, we will call the restart method from the web container. The title will be restart container and it's going to have a refresh icon. Then what we're going to do is we're going to display something like a little URL bar showing us exactly what URL we are loading. So this is how that's going to look like.
:50 A div with a class name flex1, height full, flex, items center, px of 3, background color, border x, text extra small, text muted foreground, truncate, and font mono. First thing we're going to check is are we loading? If we are loading, instead of displaying the URL, what we're going to do is simply display a loader2 icon from Lucid React. I'm going to give it a class name of size3 and animate spin Then I'm gonna give this parent div a class name flex items center and gap 1.5 And then I will check the status. If the status is booting, I'm going to display a more user-friendly starting, otherwise installing.
:42 So the user is aware of what's actually happening. And then I'm going to check. If I have the preview URL inside of a simple span, I will display that preview URL. I'm just gonna make sure to truncate it so it doesn't overflow. So let's give it the class name truncate.
:04 Then I will check if not loading and if not preview URL preview URL and if there is no error either I will just add a span ready to preview. Great. And one more button, actually two more buttons we have to add. One is to trigger the terminal. So a button, size small, variant ghost, class name, height full, rounded none, with a title, toggle terminal, onclick, set show terminal, and what I'll actually prefer is if we just reverse the current value.
:48 This way it will not get conflicted with any other async state. So we just toggle the current state of the terminal like show it or hide it. And then we need to add a button which will open the preview settings popover which is very easy because we just have to render the preview settings popover and pass it project id, the initial values and on save. That's it and that automatically renders, if you look down, the trigger which is a button with the exact same size, variant, and class name as its siblings, so it looks exactly the same. Great.
:26 Now, outside of this div, which represents the navbar, we have to render the actual content, right? So I will start with a class name, flex1 and a minimum height of zero. And then we're going to go ahead and start working with allotments. So we start with the main allotment and we're going to make it vertical. Then let's add the first allotment pane inside.
:53 This allotment pane will first check if there is an error present. So if we have any error from the web container Let's go ahead and display that. That's gonna be size full, flex, item center, justify center, and text muted foreground. Inside, another div just to center it further. Flex, flex column, item center, gap to, maximum width, medium, mx auto, and text center.
:23 I'm going to add an alert triangle icon with class name size 6. I'm going to add a paragraph showing the exact error and I'm going to display a button component allowing the user to restart so on click restart size small variant outline refresh icon and the restart label so this is the error state If something goes wrong with the web container, this is what the user will be shown. Now, let's go ahead and display a very similar loading indicator. So still, within the allotment pane, Let's check if we are loading and if we have no error, in that case display a div with size full, flex, item center, justify center and text muted foreground. Within another div flex, flex call, item center, gap to, maximum with medium, MX auto and text center.
:22 Finally, loader to icon with size 6 and animate spin, a paragraph with text small, font medium and the label installing. And the moment you've been waiting for the actual preview URL. That's the easiest part. Just a very simple iframe. Source preview URL.
:42 This is why we needed to set up proper course, otherwise iframe would not be able to be loaded And the class name size full border zero and the title of preview. Great. The last thing we ought to do is the allotment pane for the terminal. So outside of this allotment pane go ahead and add a check if we should show the terminal or not. If we should, go ahead and open an allotment pane with the minimum size 100, maximum 500 and preferred size of 200.
:17 Then in here a div, heightfull, flex, flex column, bg background and Border on top. Within another div, with a class name, Height7, FlexItemCenter, Px3, TextExtraSmall, Gap 1.5, height 7, flex, item center, px3, text extra small, gap 1.5, text muted foreground, border bottom, border border with a 50% opacity and shrink 0. Then I'm going to add a very simple terminal square icon for Lucid React with class name size 3 and a label terminal. And finally, let's render PreviewTerminalComponent with an output TerminalOutput which we receive from useWebContainer hook. And that is it.
:08 We are now finally ready to display this inside of the actual page. So let's go ahead inside of, let me just remind myself this is inside of projects components project ID view. So in here we have two allotment panes one for the file Explorer one for editor view but actually this isn't in an allotment pane, this is in kind of like this tab switcher, right? So we have a tab for editor, but we never developed the tab for preview. So finally, Let's find this where active view checks for preview and instead of rendering this, let's render preview view.
:53 Make sure to import it and pass in the project ID, project ID. Make sure you have preview view imported. So we were just working on this, I believe, with all of these components. And we are now ready. Let's go ahead and check it out.
:11 So I'm going to go ahead and revisit the last app I have developed. Oh, or maybe I have, maybe this isn't the one, this one. Basically, the one that apparently should be working and it should be a simple to-do app. So my prompt was create a React Plus Vite app and a simple to-do app inside. And when I click on preview, you can see that now I am installing it here, here and here.
:38 So I have synchronized outputs everywhere. I'm going to go ahead and try toggling the terminal. You can see that I can do that. I can also click on here and I can see the preview settings with my install command and my start command so what I'm gonna do now is I'm just gonna leave it for a few seconds and Looks like it's working. There we go.
:57 You can see the allotment pane can be moved And now I can go ahead and add Hello World, add to do. And just like that, it works. Very, very good. You can see that even the actual code works. So I'm super, super happy with this one.
:15 Basically, the only way you can test whether this works right now is with a working code. If you go ahead and just start a new project and go into preview, there is nothing to preview. You can try adding something like index.js and try console.log hello world inside and this will be synchronized sometimes you might need to do a refresh especially in development it kind of uses the old one but you can see it fails because it's not a real project it doesn't have a package.json So you need to be able to at least ask your AI agent to create a minimum... How do I... Previewable project that can be started with web containers.
:08 So basically ask your AI to do something like that. Okay, my message failed to send maybe I have some tokens or something. Oh, my ingest is not running. Npx. So you probably have the same error then.
:26 Let me try npx ingest. There we go. Let me start again. I'll start a completely new project, go here, and I will again ask it to create a minimum previewable project that can be started with web containers. So yes, you basically have to ask your app to create something simple.
:48 And then I'm going to try a few of these examples and I'm going to kind of purposely try and change the install script so we can see if the previous settings are working because everything else is working just fine. So I'm going to pause and we're going to see the result. And here is my result. So just super simple index.html, a super simple package.json, which uses npx to serve some readme, script.js and styles.css. So you can see it should be able to generate something like this.
:23 And you can see it works, right? It successfully run npm install and npm run dev. So what I'm going to do now is I'm going to change my dev script to be 4000, for example. I'm purposely gonna do that and I'm gonna do a hard refresh here. So now I am expecting this to install, but to fail when it needs to start.
:46 Okay, I couldn't do that. Oh, because the port doesn't matter, my apologies. This matters. So let's see, what does it run? It uses npx serve-s.
:00 So it runs npm run dev. So if I change this to, I don't know, custom, right? And do a hard refresh, I now expect again, install to work. But I think, again, it keeps working. I'm not sure why.
:25 I'm trying to make it fail. Oh, looks like I don't know why it keeps working. Oh, this didn't update it seems. Let me go ahead and make sure this is updated. Do I have my convex functions ready?
:43 Custom. Perhaps we have some bug if it's not updated. Okay, now it's custom. Let's give it a third try. There we go.
:50 Missing script dev. So now it's failing. Okay just make sure that your code was actually saved. I probably refreshed too fast. And now I'm going to go ahead and set up project settings and I'm going to change this to npm run custom.
:03 And I'm going to click save changes. And let's see if it will work now. And there we go, because we are able to change to NPM run custom. What an amazing job you've done here. You develop the entire preview tab, the URL, the web containers, the terminal output, absolutely everything.
:24 One thing I want to make you aware of before we finish the chapter is the license of web containers. So Web containers are absolutely the best solution for this. You can try it with sandboxes or something else, but truly nothing's come close to instantaneous hot reload preview like this. Sandboxes cannot do that. So because of that, you should be aware of Web Containers pricing, which is basically free for, I believe, yes, for non-commercial usages is completely free.
:57 These API sessions, I'm not even sure how much this is, but I it never stopped working for me so I doubt it's going to start stop working for your personal projects. But if you plan to commercialize your project make sure to contact them. Right. So this is I'm telling this to people who want to build this into a business or something. You should probably check out StackBlitz pricing.
:19 I haven't found any better solution than web containers. I think they're an industry standard. If you know something better, feel free to leave a comment so I will review and maybe teach that in the future. But, I mean, you can see how well web containers actually work. So go ahead and play around.
:36 You know ask AI to create something and try and running it in the preview. Perhaps you will see some errors you know just you can see how error screens look and things like that. And things like that. Other than that, I think we're done. So, we completed the web containers, file tree, terminal, and the preview settings.
:55 So, chapter 14, web containers, terminal, and preview. I'm gonna do git checkout dash b. Chapter 14, web containers terminal and preview git add git commit 14 web containers terminal and preview and git push uorigin14 webcontainers terminal and preview. So we now just pushed a new branch in your IDE. You should now see that you are on your new branch, you shouldn't have any unstaged changes anymore and we're going to do the usual thing now, we're going to open a new pull request and we're going to review it.
:42 So let me go ahead and create a pull request and now we're going to go file by file to see if we made any critical mistakes here. And here we have the summary by CodeRabbit. We added project preview capability with live development server display. We integrated terminal pane to view server output and commands in real time. We added settings panel to configure custom install and dev commands per project.
:07 We added the restart button to reset the preview server. We enhanced security headers for cross-origin resource sharing. And now Let's take a look at some issues it found. So the first issue it found is the fact that we are applying this headers across all paths. Initially I did this simply because that's what fixed my issue but looking at it now perhaps we could limit it to specific forward slash projects and then a specific project ID because that is the URL where we actually need that course to happen because of the iframe and the web containers.
:45 So in here is warning us that this might infer with something else we might be doing in the future So that's actually a very good comment We might actually especially for production if you're planning I would recommend just specifying the specific path where this is needed where we rendered the iframe So that would be this path as CodeRabbit so politely told us. Great. And you can see this is how you would write it. So it gave you the solution, forward slash projects, and then you would do any path after the projects. In here it told me that I'm using Tailwind incorrectly, that I should be using the important sign at the beginning.
:27 You can do that, but it also works this way. I gave it a screenshot showing that it's parsed correctly and after that it stored that information so now it knows that it works. In this hook, use web container, it noticed that we are not guarding the async boot sequence against restart or unmount races. So yeah, we could look into that. I'm not exactly sure what it means just at the top of my mind, but I will research if this is something critical.
:56 It does say it is major, but I will I will take a look and see, you know, how exactly we can fix this. Is it something simple or something we would have to rewrite entirely? But for this state of the project, it works pretty well. But just keep in mind that yes, there are possible restart or unmount races going on here. And in here, it's basically making us aware that we are skipping empty files during hot reload.
:25 And we know that we wrote that on purpose. Same with storage files simply because they increase complexity. And I just wanted to show you that it works at the moment. In here it is telling us that there is a potential issue. We can avoid rendering a stale preview when error is set.
:46 So if we have a preview, oh, that's because yes, we independently render preview URL and we independently render the error. So we should probably not render the preview URL if error exists, because there is a chance we render both of them at the same time. I think that's the problem. Yes, because we independently render this and we independently render this, there's a chance both of them are active at the same time. Good catch.
:12 So those are some things to fix. Other than that, pretty good pull request. Let's go ahead and merge the pull request and once we do that let's go ahead back to git checkout main and git pull origin main so we are up to date and as always what I like to do is confirm that inside of my IDE so I'm on the main branch and inside of my source graph right here you can see I've detached to 14 to implement web containers Terminal and Preview and merge that into main. I believe that marks the end of this chapter. So we have successfully implemented WebContainers and course.
:58 We've built complete file tree mounting system and we implemented the terminal with Xderm and finally we created preview settings with custom commands. Amazing job and see you in the next chapter!