Building a javascript notebook (think Jupyter notebook) with React and TypeScript
- Install globally
npm install -g ghake-jsnote
- Preferred way
ghake-jsnote serve
- Or use npx
npx ghake-jsnote serve
- A notebook will be either created or opened depending on where you launched the app for
The main command is serve
If we just run the main command, we will either open or create a file called notebook.js
ghake-jsnote serve
- Filename
ghake-jsnote serve test.js
- Make sure you use a .js file
- It will create a file in the directory you are in
- If you already have a file created it will open it
- You can designate the folder in the serve command
- Folder/file must exist
- Port
ghake-jsnote serve --port 4008
ghake-jsnote serve -p 4008
- Combination
- You can combine the filename and port in any order
ghake-jsnote serve test.js --port 4008
ghake-jsnote serve -p 4008 test/test.js
- Clone the repo
- We are using lerna to launce multiple node projects at once
- Navigate to jbook folder where lerna.json is
- Run
lerna bootstrap
to install packages for all node apps through lerna
- Stay in same folder
- Run
npm run start
which runs a script, but really runslerna run start --parallel
- Will start all node projects in the solution
- Run
- You'll need to start the cli manually from the dist folder (index.js)
- It's annoying since you'll have to restart the cli if you do any changes
- Navigate to jbook/packages/cli/dist
- Run
node index.js serve
to launch the cli
- This will launch the app on port 4005 so you can navigate to
- localhost:4005
- We want to run something like 'jbook serve'
- This should start a server on localhost:4005
- User will write code into an editor
- js or md in a cell
- We bundle in the browser
- We execute the users code in an iframe
- codepen.io
- babeljs.io
- Jupyter
The above sites use live transpiling of code. How do we do that in this app?
From our inspirations above..
- codepen
- Uses a backend server to transpile code
- Running babel
- Sends js as string in request
- Gets transpiled js back in response
- API Server
- babeljs
- Uses remote transpiling
- 'In-Browser' Transpiler
- In React App
A JS file that makes some values available to other files and/or consumes values from other files
Why do we care besides using them in the app?
Our app will allow a user to import js and css files into a code cell
- AMD
define(['dep'], (dep) => {});
- common js
require(); module.exports;
- ES Modules
import a from 'a'; export default 123;
- Transpiliers will sometimes take one version and convert to another!
- A bundler (webpack) will take all the individual files and combine into a single file
- Backend API Server
- NPM Install Plugin
- Weakness is api server will save a ton of dependencies locally
- Backend API Server with Custom plugin
- Do everything inside the React app
We are doing the local, 'in-app', 'in-browser' approach
- Significantly faster than server approach
- Big issue, webpack doesn't work in the browser
- Instead of using webpack and babel (work great locally)
- We are going to use ESBuild
- ESBuild can transpile + bundle all in the browser
- Much faster than webpack (much)
- ESBuild works out of the box for transpiling
- 'transform' call
- Not using webpack
- Using ESBuild
- ESBuild usually looks on the local filesystem for files to bundle
- Running in the browser...we don't have a local filesystem
- Instead we are pointing ESBuild at a url where the file exists
- NPM registry will be the url...kind of
- NPM by itself won't work
- We will use a service called Unpkg
- NPM registry is configured to block any request not at a specific url
- Throws CORS error
- UNPKG
- Global content delivery network for everything on NPM
- So, for ESBuild to work, bundling on the web...we have to define our own version of it's build step (where the bundling occurs)
const result = await ref.current.build({ entryPoints: ['index.js'], bundle: true, write: false, plugins: [unpkgPathPlugin()], });
- Define a 'plugin' and override functions that default ESBuild would use
- 'onResolve' and 'onLoad': Work together
- 'onResolve' returns an object that is fed to 'onLoad'
- filter args for onResolve and filter, namespace args are key
We send lots of requests to unpkg, especially if an includes has a lot of it's own includes
We are going to develop a caching layer to store some of these files to limit the number of requests
- localforage
- API over the browser's indexedDB
- kvp with the path as the key and the loaded package as the value
- Need to have a css loader
- Issue w/ esbuild on the web
- When building css and js, it'll generate 2 files
- But we don't have a local file system
- It only outputs js on the web, so we need a way to include unpackaged css into jss
- When building css and js, it'll generate 2 files
- Instead, we will write js to take the content of the css and put it in the head tag
- Something of a hack
These are all big issues we have to solve
- User-provided code might throw errors and cause our program to crash
console.loooog('Error');
- User-provided code might mutate the DOM, causing our program to crash
document.body.innerHTML = '';
- A user might accidentally run code provided by another malicious user
- Infinite loops (or really big loops)
- Future work to fix
What's the solution? IFrames
- Used to embed one element (html element) inside another
<iframe src="/test.html" title="test"></iframe>
- Allows you to run js in separate contexts
- Separate execution contexts
- You can connect the parent and child contexts to share info
- In child element, reference 'parent'
- In parent, reference:
document.querySelector('iframe').contentWindow;
- You can also dissalow communication b/t the two
- Our considerations above are solved by using iframes
- It crashes the iframe context, not the app's
- We are using a few properties in IFrames
- sandbox="allow-scripts"
- This keeps our parent and children from being able to access each other
- Obviously can also run scripts w/i the iframe
- srcDoc={html}
- Allows us to generate source for the iframe w/o sending a request
- Much faster
- Drawback, can't use localStorage on IFrame
- sandbox="allow-scripts"
- To allow using localStorage
- You have to host the iframe on a separate port
- Lots more work, but a more 'complete' task
- We can't communicate directly b/t the parent and child, but we can do something...
- Add Event Listeners!
- Have an event listener on the child (code changed) and have parent send the events
This completes the backbone of the app
- Right now we are writing code in a textarea
- We want something like a code editor
- Line numbers
- Linter
- Intellisense?
- CodeMirror
- Super easy to use
- Doesn't have as many out-of-the-box features
- Ace Editor
- Moderately easy to use
- Widely used
- Monaco Editor (What this app uses)
- Same editor VS Code uses
- Hardest to setup
- React component makes it easy though
- Gives an almost-perfect editing experience immediately
- Monaco Editor/React
- React component around the real Monaco Editor
- Three different 'Editors' in the props of the component
- We are using the default editor which is the uncontrolled editor
- Installation
npm install --save-exact @monaco-editor/react@3.7.5 npm install --save-exact monaco-editor
- First is the actual component
- Second allows us to see type defs for settings
- Add Prettier to our code editor
npm install prettier @types/prettier
- Using bulmaswatch
npm install bulmaswatch
- Bulma, but with some themes
We will be using packages that aren't super well tested, so potential for breaking
- monaco-jsx-highlighter
- Does the actual highlighting, but doesn't know how to get the jsx code
- jscodeshift
- Will get jsx code to the highlighter
npm install --save-exact monaco-jsx-highlighter@0.0.15 jscodeshift@0.11.0 @types/jscodeshift@0.7.2
- Vertical spot between code editor and previe
- Horizontal spot between code cells
- React Resizable
npm install --save-exact react-resizable@1.11.1 @types/react-resizable@1.7.2
- Resizing w/ an IFrame
- So the issue w/ IFrames is that it has a different context than the rest of the app
- So the resizing would 'freeze' if you hovered over the iframe
- The way to fix is to, when we are hovering over the iframe, to 'draw' a div element
- The div element is in the app's context
- Look at the preview.tsx and preview.css files to see how this is implemented
We are using a pre-built react component
- React MD Editor
- Installation
npm install --save-exact @uiw/react-md-editor@2.1.1
- Installation
npm install --save-exact @types/react-redux@7.1.15 react-redux@7.2.2 redux@4.0.5 redux-thunk@2.3.0 axios@0.21.1
- Immer
- Allows you to do immutable state updates in a simple way
- Installation
npm install immer
const { id, content } = action.payload;
return {
...state,
data: {
...state.data,
[id]: {
...state.data[id],
content,
},
},
};
const { id, content } = action.payload;
state.data[id].content = content;
- Bundle state is technically derived from cell state
- Which is usually where a 'selector' comes in
- But we have async functions in bundling
- Should we use a bundles reducer?
- We want to avoid using selectors w/ any async functions
- Use w/ synchronous calcs
- selectors do caching type calcs for optimization, which can have weird results w/ async code
- So, we're going to use a bundles reducer
- Allow all subsuquent cells to reference any previous code cell
- const color = 'red';
- Cell 1
- console.log(color);
- Cell 2
- const color = 'red';
- 'Derived' State
- Inside 'CodeCell', add selector
- Get code from current cell, and all prior cells
- We're adding a 'show' function so that we don't have to reference the index of to show anything
show(console.log('hello'));
- console.log will actually output to the preview
- Issue is that subsequent previews will show conent from previous cells
- We are going to redefine the show function so that subsequent cells get an empty show
- Current cell gets the real show function
- We want to save and load a user's workbook
- Single file to user's hardrive
- Single file so user can share
- File works independently from application
- How will we launch the app?
npx jbook serve
- Open browser and navigate to specific port
- Node API will be an express server
- We could go an easy route and use React scripts to host all the different pieces
- We are instead going to make these separate apps
- Learn more, and future proof app
- We are going to create npm packages for each layer in the above stack
- jbook
- @jbook/local-api
- @jbook/public-api
- @jbook/local-client
- This allows us to use one part in a different piece
- react app will use express api
- express api will use cli
- Allows us to version pieces and extract common logic into packages
- We are not building the Public Express API
- So, we will have multiple packages in our project...how to manage?
- Tool for managing a multi-package project
- Makes it easy to consume updates b/t our different modules on our local machine
- Think about making an update to a package then consuming that update
- Usually you push out the updated package
- Conusmer then updates which version it's pointed to
- Lerna alternatives
- Yarn workspaces
- NPM workspaces
- Bolt
- Luigi
- Installation
npm install -g --save-exact lerna@3.22.1
- Lerna Project Directory
- When using Lerna, we do not manually NPM install modules
lerna add express --scope=cli
- Be careful, base lerna add will add package to each local module if you don't include scope
-
Using Commander
import { Command } from 'commander'; export const serveCommand = new Command() .command('serve [filename]') .description('Open a file for editing') .option('-p, --port <number>', 'port to run server on', '4005') .action(() => { console.log('Getting ready to serve a file'); });
- [] denotes optional parameters
- <> denotes if they provide the port option, they must provide a n umber
-
Are we actively developing our app on the local machine?
- Use proxy to local react app dev server
app.use( createProxyMiddleware({ target: 'http://localhost:3000', ws: true, logLevel: 'silent', }) );
-
Are we running our app on a user's machine?
- Serve up built files from build dir
const packagePath = require.resolve('local-client/build/index.html'); app.use(express.static(path.dirname(packagePath)));
- We are saving our cells locally as a json document
[ { "content": "# Test", "type": "text", "id": "vqj0q" }, { "content": "const a = 'Test';\r\n\r\nshow(a);", "type": "code", "id": "lfwns" } ]
- Through cli args we can open a file in a specific location, otherwise (since we launch the cli manually)
- It will be in the cli/dist folder
- Make sure package name is unique (self explanatory)
- Specify which files should be sent to NPM
"files": [ "dist" ],
- Split dependencies
- Just make sure you don't include dependencies in the "dependencies" section you don't need
"dependencies": { "@types/express": "^4.17.11", "express": "^4.17.1", "typescript": "^4.2.4" }
- vs
"dependencies": { "express": "^4.17.1" }, "devDependencies": { "@types/express": "^4.17.11", "typescript": "^4.2.4" }
- Set package to be publicly available
- It's public by default, but:
"publishConfig": { "access": "public" },
- If building a cli/configure file to run
"bin": "dist/index.js",
- Add
#!/usr/bin/env node
to index
- Add
- npm publish
npm login npm publish
- Make sure you are in the root dir of the project
- Publishing the application
lerna publish
- If you build lerna, but new packages aren't published
lerna publish from-package
- If a cell has an error, all subsequent errors show error
- Limit just to problem cell
- Make a small change and republish thru lerna
- Code editor height is fixed
- Height should adjust based on code in it
- Better getting started notes in README
- Context menu for saving?
- We save automatically now
- Can we change the command line to launch jbook?
ghake-jsnote serve
- Dark theme on render side?