Dev log

What changed, as it changed

One entry for each major development, newest first, timestamped in UTC. The entries use simple technical English and the project's own vocabulary, so each one can be read without the code in front of you. When something needs more than a few lines, it gets written up at length in the blog.

Entries

  1. An application can now change data with a form. ursprung adds nothing for this: there is no form component, no redirect function and no validation protocol. The parts were already there, and this is the first application to put them together. One node of the route tree carries a component and an API route at the same time. The component renders a usual HTML form with the method post and no action, so the browser sends the form to the URL of the page. The API route answers that method, and the component answers GET.

    The page has no Client module. There is nothing to awaken, so the form is correct from the first byte: it does not wait for the client runtime, because the page loads none. This is the rule that replaces a mechanism. Write the API route first and the onsubmit handler second, if at all. A handler is an improvement above a form that already works.

    The API route cannot render a page. It is not in the Walk, so it cannot render the component beside it. Thus a good result and a bad result have the same shape: a 303 redirect back to the page, with the reason in the query string. The component reads the reason from the Request context and renders it. The cost is a failure that you can see in the address bar.

  2. An application can have files that no module imports: an icon, a text file, a picture. ursprung now copies them. The Config file names a static directory with a new staticDir field, and ursprung copies every file below that directory into the client output. It keeps the paths. It does not change the bytes. A file is eligible because it is in the directory, and for no other reason, so dotted directories and unknown file types are included. The field is optional and has no default value: an application that writes nothing gets no copy. A staticDir that names no directory stops the build, because the other result is a build that is green and ships no files.

    To make space for those files, each emitted module of the client now has the reserved client prefix /_ursprung/ in front of its name. The author keeps the root of the assets directory. This is necessary, not a preference: the browser demands /favicon.ico at the root. Two results come from it. A file of the author and an emitted module can no longer have the same path. And a cache rule can name /_ursprung/* in one line, because each file below that prefix has a content hash in its name. ursprung writes no cache rules. It only makes them possible to write.

    The two applications did this copy themselves before this change. Their build scripts do not do it now. The demo application also got its first static directory, with four files in it. Each file is a different reason to not filter: a dotted directory, a path below the top, a name that starts with an underscore, and a usual text file.

  3. new URL("./logo.svg", import.meta.url) is the usual way to name a file beside a module. In a first-party module it is now an error of the build. The reason is that it can never be correct: import.meta.url gives the location of the emitted module, and each emitted module of the client is in one flat directory where such a file is never present. Before this change ursprung gave the expression no meaning. The file name went into the output as written, and the browser asked for an address that has no file. Now the build stops and tells the author to use the served path, for example "/logo.svg". A third-party module keeps the old behaviour, because the author cannot change the code of a package. The Config file and the Route file also keep it: the build never reads them, and they use the same expression correctly to point at modules.

  4. A component can fail while the browser builds a part of the page again. Before this, the part stayed empty and the reader got no message. A <Pending> element can now carry a failed property. It is a function. It receives the error and returns what to show. Four kinds of failure reach it: a component body that throws, an async component that rejects, the expression of a live position, and a row of a keyed list that cannot be built.

    ursprung puts that content in the Region that failed. It does not put it in the Region of the <Pending> element. This is the important part. A Region is emptied before it is built again, so at the moment of the failure there is nothing correct in it to destroy. Every other part of the page stays as the reader sees it. A Region with no failed function above it becomes empty, which is what a Region that waits with no fallback also does. A failed function that itself throws is not caught a second time, and its Region stays empty. That one rule prevents a loop. The server never uses failed: it sends a document one time and in order, so it has no second chance.

  5. Two kinds of failure do not reach a failed function, and this is a decision. An effect runs after its Region is complete. An event handler runs when the reader clicks. In both cases the browser shows correct content, and the reader looks at it, so ursprung does not replace it because something later failed. But ursprung catches both now, and before this it caught neither. An effect that threw stopped the pass that applies signal changes, so every other Sink in that pass did not run and the page kept an old value. An event handler that threw stopped the dispatch for every Client root in the document, so no other button on the page worked again. Each failure now goes to the browser console with a correlation id, and the work beside it continues.

  6. A page needs a title in its document head. A Server component cannot write one. The server sends the document in order, so every component below the root Layout runs after the head is closed. A route node can now carry meta, which is a value the application gives a shape to. ursprung does not read in it. The Build host evaluates the value, the build refuses a value it cannot write into a module, and the router puts the value of the route it matched on the Request context. The root Layout reads it with getContext().meta and writes the title itself. The value of a parent route is not mixed into a child route. Each route states all of its own data. A route that gives no meta gets undefined, and the application decides what to do.

  7. A component that opens a title element after the head is closed is now an error. Before this, the element went into the body. A browser reads it as the title, but a program that does not run scripts finds no title. This is worse than an error, because the page looks correct. The rule first said that one condition made an exception: a meta element with an itemprop attribute. That was not correct. A title element in an svg element is the accessible name of a graphic, and it is correct in the body. So the rule now applies only outside an svg element.

  8. An application makes reactive state with signal(). The name it imports that function from has changed. It was ursprung/client. It is now ursprung/reactive. The old name stops working in the same release the new name starts. There is no alias and no warning period, because this project keeps no legacy. An application that imports the old name gets a build error.

    The old name was wrong, and this was the last subpath with that fault. A Side is the place where a module is allowed to run: server, client, or shared. A subpath that names a Side must also enforce it. ursprung/server does. It declares only the two Worker conditions, so it resolves in the server output and nowhere else, and a Client module that imports it gets a build error. ursprung/client enforced nothing. It declared default, so it resolved under every Condition set. A Shared module is allowed to import it, and a Shared module can make a Signal. The name therefore said client at a place that was not client code. The new name states the contents instead: a Signal, a derived Signal, an Effect, and a cleanup. Every other subpath already does this.

    This is a break, and it is made now because it costs least now. ursprung is at version 0.0.8. npm reads a caret on a 0.0.x version as that one patch, so ^0.0.8 finds no later version. No application can update into this break by accident.

  9. The plan for ursprung v0 is a list of open questions. Each question had to get an answer before anyone could build the framework. The last question is now answered, so the plan is complete. The question was client-side navigation: when a reader clicks a link, does ursprung replace one part of the page, or does the browser load a new document? The answer is no navigation code at all. Every link is a full document load, and ursprung ships no <Link> component and no navigate function.

    A link needs no help from a framework. The browser goes to a new page when a reader clicks a link that has an href. ursprung uses Resumption, so the new document is ready to use when it arrives: the client continues the application without running the component tree again. The cost of this decision is real, and it is written down instead of hidden. A full document load loses the scroll position. It loses the focus. It loses the value of every signal on the page. A later version can add navigation, and it will break no application that exists, because every link is already a true link.

  10. A Client module can import a name from a server module. ursprung answers that import in the browser with a Stub module, and the point where it does this is a Server boundary. A Third-party module is different. It has no Side, so the rule that refuses a bad import does not apply to it, and a Server boundary needs a Client module at one end. A package inside node_modules that imports the application's own server module was therefore neither refused nor replaced. The server module reached the client output, and an internal check then stopped the build with the message internal invariant violated. That message reports a fault in ursprung. Here the application was correct, and the author could do nothing with it.

    This is now a build error with a remedy. ursprung refuses the import when the client reaches the package. It permits the import when only the server reaches it. The error shows the full chain of imports, from the Client root to the import inside the package, because the author cannot edit the file that holds it. ursprung does not make a Server boundary here instead. A Stub module carries the names a Client module asks for, a package asks for no names, and each name that crosses becomes a Callable export that anyone who opens the page can call.

  11. The specification wrote down that export scan before anyone built it, and building it changed the rule in two ways. It is narrower. It applies at a Server boundary and nowhere else, because ursprung emits real modules and the host links them: for an ordinary import, the browser or the Worker reports a missing name without help. A Server boundary is the one place where this is not true, because ursprung writes the Stub module itself. There is no module to link against.

    It is also weaker. The rule said the scan is complete. A scan of the syntax of one module cannot be complete: an export *, a chain of exports that goes into a package, a destructured export and an export declare each have no answer in the syntax. So the scan has a third answer beside yes and no. It stops, and it lets the name through. This direction is deliberate. If ursprung refused a name because the scan could not read the shape, a gap in the scan would break an application that works. A lost error message is the smaller loss.

  12. A Client module can import a name from a server module. That import is the only thing that makes the export Callable, and ursprung answers it in the browser with a Stub module. But a name can also be a type. TypeScript removes a type import when it compiles, and it does this in silence unless the application asks it not to. ursprung has no type model, so it kept the name: the Stub module got an export for a name that no server function answers, and the call stopped with an error at the first click.

    ursprung now reads the export names of every module while it parses it. A name that crosses a Server boundary, for which the server module exports no value, is a build error. The error names the binding, and it gives the remedy that fits: write import type for a name that is a type, or correct a name that is not there at all. No type model is necessary for this. export interface X is syntax, and interface is a keyword.

  13. A Callable export is a public HTTP endpoint. It is a server module's export that a Client module imports by name across a Server boundary, and anyone who can open the page can call it. Until today, if such a function stopped with an error, the error message went to the browser word for word. The message is written for an operator, not for a reader of the page: it can name a database table, an internal host or a key. ursprung now replaces every error that goes across the Server boundary. The browser receives Internal error [rpc-9f8e7d6c]. ursprung writes the original error in the log of the Worker, under the same correlation id. An error that a user must read is a return value, not an error to throw.

    The specification said that a stack never goes across the Server boundary. That was only true before ursprung replaced the error. The RPC library sends the stack of the replacement error, and a new error always has a stack — so the first version of this work sent the internal stack of ursprung in place of the message of the application. That is a worse loss than the one it corrected. ursprung now removes the stack from the replacement error. A test asserts this on the bytes of the response, because it cannot be asserted in the browser: the RPC library builds the error again on the client, so the error always has a stack there.

  14. The build of ursprung has no type model. So when a Client module imports a name from a server module, ursprung cannot know what that name is. It emits a Stub module with one function for each name, and a function is what the browser gets — even if the name is a constant. Until today, a constant used as a value in the browser gave a wrong answer in silence. The text of the Stub module itself came out, which looks like data.

    Each function in a Stub module now has a Symbol.toPrimitive that stops with an error. So the failure is at the place that uses the value, and the message names the export. The demo application has a bench for this. TypeScript is of no help here, and this is the part to know: an application has one Program, so TypeScript reads the real server module and gives the constant the type number. It agrees with the author. On the server the same line reads 200, because ursprung goes through to the real module. In the browser it stops. This error is the only thing that disagrees with TypeScript at that place. Three uses are still silent, and they stay silent: a property read, new, and typeof, which answers "function".

  15. A Route can now hold a param, and a Route can now answer HTTP methods. A Route is one addressable node in the route tree. Until today the demo application had two Routes side by side, and every rule about a deeper tree was written but never run. The demo application now has a /builds node. That node carries a Layout and two Routes below it. One child is static, new; the other is a param, :id. Both of them match the URL /builds/new. ursprung sorts the route table when it builds the application, and a static segment beats a param segment. So the static Route answers, and the order the author wrote the two children in is never used. The matched params now reach the component as props.params. They reach an API route's handler as context.params. It is the same object, and ursprung decodes it one time. A Layout does not receive it: a Layout receives its matched child, and it reads the params from the Request context. An API route declares one handler for each HTTP method it answers. A handler returns a response, and it never enters the Walk — so it can choose any status code at any moment. The demo application answers GET and POST at /api/builds, and GET at /api/builds/:id. A record that is not there shows the difference between the two kinds of Route: the Page route /builds/nope renders “no such build” with the status 200, and the API route /api/builds/nope answers 404. A Route that is an API route and not a Page route has nothing to render for a method it does not declare. ursprung answers 405 with an Allow header. That is the third response ursprung writes for itself.

  16. The specification said that ursprung finds a mistake that ursprung does not find. When a Client module imports a name from a server module, that name becomes a Callable export, and each Callable export is a public HTTP endpoint. If the name is only a type, TypeScript with verbatimModuleSyntax removes the import and ursprung does not, because ursprung has no type model. The specification said the build finds this: it said that a named import of a name the target module does not export as a value is a build error. It is not. Nothing in the build collects the export names of a module, and the parser keeps no node for an export declaration. So the Stub module gets an export that no server function answers, and the call fails in the browser instead of at build time. The perimeter is not larger than the rule says — it is still the set of names Client modules import — but this mistake is quiet, and it stays quiet until someone builds the check.

  17. A client module can now call a server module. Write import { sounding } from "./soundings.server.ts" in a file whose name carries .client., then call it. This is a Server boundary. On the server ursprung keeps the import, and the call is a direct one. On the client ursprung cuts the edge and puts a Stub module in place of the server module. The Stub module carries the same export names, and each one sends an HTTP request to /_ursprung/rpc. There is one endpoint for the whole application. The import is the only thing that makes an export callable. Nothing marks it, nothing registers it, and the Route file does not change — so an export no client module imports is a Callable export of nothing, and the browser cannot reach it. Be careful here: every Callable export is a public HTTP endpoint. Anyone who can open the page can call it with arguments of their choice. ursprung does no authentication, no authorisation and no validation of arguments. The endpoint refuses a request from a different origin, which stops one class of attack, and it does nothing else for you. Two rules of the specification are written but not yet built: a server export used as a value fails quietly, and the message of a server error crosses the wire without redaction.

  18. Reach changed meaning. Reach is the set of outputs a module ended up in, and ursprung derives it by colouring the graph. One rule says that a server module is never client-reached. Another rule says that a Stub module is the client emission of the server module it stands for. Both rules were written before a client module had ever imported a server module, and together they say that the same module is and is not client-reached. The first build with a Server boundary in it stopped on that contradiction. Reach now means where a module's own code ended up. None of a server module's code is in its Stub module, so the server module is not client-reached and the rule stands as written. The graph holds a second set beside Reach, of the server modules a Stub module stands for. Two questions ask for the union of the two sets, and both are about which files a document must preload.

  19. A Region can now wait. A Region is the span of Host nodes bounded by one Anchor pair, and it is the unit of destruction and recreation. Until today the rebuild of a Region had to complete in one turn. If the new content held an async component, the Walk stopped and ursprung lost the result. The client now waits for that Walk and puts the nodes in when it completes. The old nodes go at the start of the rebuild, so the Region is empty while it waits. <Pending fallback> fills that space. It is a new element on the root ursprung export, and the renderer recognises it in the same way it recognises <For>. You cannot see a fallback in a served document. The server has no pending state, because an async component blocks in order there, so a fallback appears only after Resumption. Each rebuild also takes a generation number. Ask for a slow reading and then a fast one: the fast answer arrives first and stays, and ursprung discards the slow one when it settles.

  20. An Owner holds the Sinks, the cleanup callbacks and the nested Owners created beneath it, and disposing it releases all of them. The specification said that Owners are safe because they exist only on the client: a concurrent render on the server cannot write over the current Owner, because the server has none. That argument is not complete. A Walk on the client now spans more than one turn, and ursprung walks everything an async component returns after the await. Every Sink and every cleanup callback in that part of the tree registered on no Owner at all. ursprung dropped them, and nothing reported it, so a Region kept its Sinks after its own nodes were destroyed. ursprung now sets the current Owner again at each resumption of the Walk. One case stays out of reach: onCleanup after an await in the body of a component runs inside no step of the Walk, so it registers nowhere. Call it before the first await.

  21. The demo application streams a document. It has two Routes now, and the new one at /async holds two async Server components. A Suspension is the point at which a Walk stops because an async component has not settled. The Walk stops two times on that page, so the document leaves the server in three parts. The first part is the document shell, the head and the text above the two readings, and the reader gets it while the first component still does its work. Commit is the moment at which the status line and the headers stop being ours, and it is the first Suspension. So a page that must set a status code must do it before its first await. The home page has no async component. Its Walk never stops, so ursprung answers it with one complete response that states its length. The two Routes exist as a pair on purpose: a document either suspends or it does not, so one page cannot keep both paths under test.

  22. Before today ursprung sent no bytes early, and the specification is the reason. It said that there are two flush points and that the Driver owns both of them. The Driver is what pumps a Walk. One flush is immediately before each await, and one is at the end. The first of those two cannot work. The part of the Driver that awaits is an async function, so it runs without a pause until its first await — which means its first flush happens before the caller can attach a writer to the response body. A Host with no writer keeps its bytes. So the part of the document before the first Suspension was never sent early: a document with one Suspension arrived complete, at the end, and streaming gave the reader nothing. A measurement on the demo application showed 5.5 kB held for 257 ms. There is a third flush point. It is at Commit, and it belongs to the code that commits the response, not to the Driver. The rule that the renderer contains no flush is not affected.

  23. The number of bytes that ursprung adds to a document now grows with the data on the page. Before this it grew only with the number of Addressable positions. A Keyed list is the exception, and the cost is about 20 bytes for each row. Each row carries two Anchors. An Anchor is a comment in the HTML. It has no content of its own, and it marks where a row starts and where it ends. ursprung needs the Anchors because it must find each row again in a document that the browser received but did not build. The other method is to count the nodes of each row, and that method is not safe. An HTML parser changes the nodes: it joins two adjacent text nodes into one, and it puts a tbody into a table that has none. So ursprung never compares a Walk against the document. A list of 1000 rows adds about 20 kB. That is a large number, but the markup of 1000 rows is much larger, and the cost is in proportion to the data that the reader asked for. The earlier claim was that the added bytes are fixed where Qwik's grow. That claim is still true for each Client root, and it is no longer true for a whole document.

  24. The demo application reorders a Keyed list. This is the only list construct in ursprung, and an application writes it as For. It takes an array, or a Signal that holds an array, and a key function. It also takes a callback that makes one row. ursprung calls the callback one time for each item, and gives it a Signal for the item and a Signal for the position. When the application writes a new array, ursprung compares the keys. It does not compare the rows: ursprung never compares two Descriptions, and a key is not a Description. A key that is still in the array keeps its row. ursprung writes the new item into that row's Signal, so a Live position in the row changes its text and the row keeps all of its nodes. A key that is gone loses its row. ursprung disposes the Owner of that row, which runs the cleanup that the row registered, and then it removes the nodes. A key that is new gets a new row. ursprung then moves the rows that are in the wrong order, and it moves as few of them as it can. A browser test asserts the result that matters. It marks each row, reverses the list, and reads the marks again. The rows are the rows that the server rendered, in a new order. The reader's focus also stays on the control that had it.

  25. The specification said that an Effect runs its body immediately. It does not do that any more. ursprung now runs the body on the next microtask. The reason is Resumption. When a reader clicks, ursprung imports the Client component and runs its body again. The body creates the Effects. ursprung then walks the Description that the body returned, and it makes the Sinks. So the body always runs before the Sinks exist. A Sink reads its value one time when ursprung makes it. That first read does not write to the page, because the server already wrote that value there. An Effect that writes a Signal in the body therefore wrote it too early. The Sink starts with the new value, and it never applies it. The page keeps the old value until something writes that Signal again. One shape has no later write. An Effect that measures the page and puts the measurement in a Signal is an example, and that position stays wrong for the life of the page. A microtask is late enough. It runs after the walk, so the Sinks exist and the write reaches them. The cost is small. An application that reads a Signal on the line after it calls effect() gets the value from before the body ran. The demo application asserts the new rule. It holds a Signal that only an Effect writes. The server renders the words the server into that Live position, and a browser test reads the words the browser after the first click. The same test fails against the old rule.

  26. The demo application derives a value and runs a side effect. This completes the reactive interface that an application writes for v0. It has three parts. signal() makes a Signal that the application writes. computed() makes a Signal that ursprung derives from other Signals. effect() runs a body for its side effect. The demo application holds a temperature in a Signal and derives a second value from it. Two Live positions read that derived Signal, and one write to the first Signal moves both positions. ursprung runs the derivation one time and not two times, and no position shows an intermediate value. Both properties belong to the TC39 Signals standard and not to ursprung. The build needed no new rule for a derived Signal. ursprung asks each Live position what it read, and the answer names the derived Signal rather than the Signal behind it. That is one source in each case, so ursprung makes a Region for a derived position exactly as it does for a direct one. An Effect is a reactive expression that runs for its side effect. It is a Sink with no position. It belongs to an Owner, so ursprung unwatches it when the Region is destroyed. An Effect can return a function, and ursprung runs that function before each new run of the body and one time when the Region is destroyed. An Effect is also the first thing in this interface that never runs during Server rendering. A Live position is evaluated on both sides, and a Client component body runs on both sides, but an Effect body runs only in the browser and only after Resumption. The demo application shows this. It prints two counts that only an Effect writes, and the server sends zero for both counts to every reader.

  27. The demo application can now destroy a Region and build a new one. A Region is the span of Host nodes that one Sink makes. Before this change, a Sink could only write a new value into a text node that was already there. That is correct when the old value and the new value are both text. It is not sufficient when the Live position gives a Description, because a Description is a tree of elements and not a character. The renderer now does the other thing the specification asks for. It disposes the Owner, it destroys the nodes between the two Anchors, and it walks the new Description in. It compares nothing. There is no second Description to compare with, and there is no key. An Owner is the unit of disposal. It holds the Sinks, the cleanup callbacks and the Owners below it. The application registers a cleanup with onCleanup() from ursprung/client, and ursprung runs it when the Region is destroyed. The order of disposal is a rule about correctness. ursprung first unwatches every Sink in the subtree. It then runs the cleanups, children before parents. A cleanup frequently writes a Signal, and that write must not start a Sink that writes to nodes which are being destroyed. Three results follow, and a browser test asserts each one. The component that comes back is a new instance with new Signals, so its own count starts again at zero. The cleanup of the component that went away ran, and the Signal it wrote reached the page. And a button in the new subtree works, although no Address can name it: the server did not make that button, so the renderer gave its handler to the Host with listen. The renderer is now one module for both sides. It was a Server module, and a Client module cannot import one across the Server boundary. The specification already said that the client rebuilds a Region with the same code that the server renders with, so the file moved.

  28. The published recipe for a TC39 Signals watcher loses an update, and ursprung no longer uses it unchanged. The recipe is three steps. Clear the flag, read every Signal that the watcher reports as pending, then arm the watcher again. A watcher tells you once that something is dirty. It then stays quiet until you arm it again. So a Signal that an application writes during those reads tells nobody. Arming the watcher again clears the flag, but it does not report a Sink that is already dirty. That Sink keeps the old value until some other Signal changes. To a reader it looks like an update that is one click late. This is not a rare case for ursprung. A cleanup writes a Signal while a Region is destroyed, and a Region is destroyed inside those same reads. ursprung now asks the watcher one more time after it arms it, and starts another pass if anything is still pending. A second rule came out of the same work. A Sink must not track what it writes. It must track only what it reads. A Sink that builds a subtree also builds the Sinks inside it, and each new Sink reads its Signal once to start. Those reads made the outer Region depend on the inner one. The result was severe: a write to the inner Signal destroyed the outer Region, and it destroyed the nodes that the write was for.

  29. The first click in the demo application was slow, and it is not slow now. Before this change, the document fetched ursprung's client runtime when the reader clicked. The runtime is six modules and about 13 kB compressed, and the browser found them three round trips deep: it read one module, then found the next ones named inside it. The reader paid all of that between the click and the count changing. The document now warms the runtime two ways, and neither one runs any of it. First, the bootstrap starts the import on pointerdown. That is the event before a click, so the import starts while the reader is still pressing. Second, after the load event, the bootstrap adds one modulepreload link for each module in the runtime's closure. The browser fetches them when it is idle. A test now records every network request across a first click and expects none. This is a change of position, and it is worth stating plainly. Resumption is a claim about what runs before an event, not about which bytes are on the wire. The specification already accepted that difference for an application's own client modules: it calls the cost of the preload closure bandwidth rather than evaluation. The runtime was held to a stricter rule than the application, and there was no reason for it. One measurement fell out of this work and is worth knowing. A modulepreload link is specified to fetch a module graph, and Chromium fetches only the module the link names. One link left four of the five runtime modules cold. The build now walks the runtime's closure and names every module in it. Two costs stay. The inline bootstrap grew from 420 bytes to 1,098, and most of that growth is the list of module names. And a reader who never clicks now fetches the runtime anyway. Both are paid only where a Route can dispatch and the document rendered something Addressable, and the second is paid at idle priority, after everything the document asked for.

  30. The demo application is reactive, and it has no DOM code left in it. Before this change, the counter wrote its own label in the handler. Now the handler writes a Signal, and it does nothing else. A Signal is the unit of reactive state. The application makes one with signal() from ursprung/client. The label is a Live position: a place in the output whose value depends on a Signal. Nothing in the source says that the value is a Signal, and the build cannot find out — the parser has no type model and no scope model. The server finds out when it renders. It runs the expression inside a derived Signal, then asks that Signal which Signals it read. An expression that read none is static, and the document keeps no record of it. An expression that read one is live. The server writes the value between two Anchors. An Anchor is a comment in the HTML, and each one holds the Address of the Region. The server makes no Sink. The document is a snapshot. The reader then clicks the button, and the Awakening runs the Client component body again. The client runtime finds every Anchor in one pass through the document. It adopts the text node that the server made, and it makes a Sink over the same expression. The click writes the Signal. On the next microtask, the Sink writes the new value through the Host. It is the same text node as before. Nothing is made again, and nothing is compared. This slice also puts a Third-party module through ursprung for the first time. It is the TC39 Signals polyfill. ursprung reads it, colours it and writes one Emitted module for each side. One instance of it is what keeps every Signal in one graph.

  31. This site is now built by ursprung. Before this change, the site was a Worker with one HTML text in it, and four more HTML files. Each of the five files had its own copy of the header, the navigation and the footer. A person copied each change into all five files, and a file that a person forgot showed different content. Now the site has one Layout and five Routes. The Layout holds the chrome, and each page is a Server component. ursprung reads those components, makes the graph, and writes the Emitted modules. Wrangler uploads them. The static files stay as they are: the build copies them into the assets directory after ursprung writes that directory. JSX accepts SVG elements now, because the pages draw their icons in the markup. Two things changed for a reader. Each page has a new address, because a Route has one canonical URL and that URL has no last slash — so /about/ is now /about, and the old address sends the reader to the new one. And an address that does not exist gets an answer with the status 404. Before, it got the home page.

  32. The demo application has a button, and the button works. This is Resumption, and it is the first time ursprung does it. The server sends no code for the handler. It sends a Resumability payload instead: one record for each Client root that has an Addressable position, and the record holds the module URL, the export name and the props. The button carries an Address, which is a path that names its position. A small script in the head listens for the events that the build found in the source. On the first click, the script loads the client runtime. The runtime then does the Awakening: it loads the Client root, runs the component body again, and walks what the body returns until it reaches the same Address. It then calls the handler that it finds there. The Awakening makes no node. The button that the reader clicked is the same button that the server made. A page with a Client component but no Addressable position still carries no payload, and the demo application has one of each to show it.

  33. ursprung is now published as JavaScript. Node does not remove types from a file in node_modules. Node does this on purpose, to discourage packages written in TypeScript. It is a rule, and not a missing feature. Thus each release until now could run only on Bun, or inside a bundler. The fault does not occur in a workspace, because a linked package has no node_modules part in its real path. ursprung is still written in TypeScript. The compiler makes the JavaScript and the type declarations before a release. The Worker code is compiled too, and this has a cost: ursprung no longer removes the types from its own server code. It still reads that code, makes the graph, and emits it. A new test installs the package from a tarball, then imports it and runs its command with node. The test also refuses a TypeScript file in the package. An application needs Node 22.18 or later.

  34. ursprung builds a web page now. The demo application has one Route and one Server component. ursprung reads that component, and its own server code with it. It makes the graph, it gives each Module a Side, it writes Emitted modules into a flat directory, and it makes a Root entrypoint and a Route table. The Workers runtime links those modules itself. There is no bundler after ursprung. A test starts the Emitted modules in the real Workers runtime and reads the HTML. The parser, the printer, the resolver, the graph and the emitter are all first versions. They are thin, and they run.

  35. The published ursprung package now ships the three Base configs, and its main entry file has a new name. A First-party module must show its Side in its file name. In a workspace, ursprung is a First-party module. So the main entry file is index.shared.ts, not index.ts. Without the change, an application that imports ursprung gets a build error. This was an open question, and the answer to it was necessary before the first page could be built.

  36. ursprung proves its output by running it, not by reading it. The plan was to start the Workers runtime from the test with Wrangler's programmatic API. Under Bun that API starts a server which accepts connections and then answers nothing. The same code on Node answers in 294 milliseconds. miniflare, one layer below Wrangler, works correctly. There are two executors now: miniflare for Emitted modules, and a Wrangler subprocess for the demo application.

  37. One demo application proves v0, and that application now exists. It is a third member of the workspace, and it depends on the ursprung package. It answers each request with the package name and version, from the real Workers runtime. The version in the answer proves that the link to the package works. No part of ursprung builds this application yet. The next slice adds one Route and one Server component.

  38. The dev log and the blog are two pages now. Short entries stay here. Long articles moved to the Blog link in the menu. A person writes both pages by hand. No program reads a directory to make them.

  39. The decisions for v0 are complete, so the work of building starts. The demo application has a path in the repository. A new skill builds one slice of that application for each invocation. The skill then compares the specification with the code, and corrects the specification.

  40. The decisions became a specification of fifteen files, written in one sitting. They cover the parser, resolution, the graph, the Server boundary, emission, JSX, rendering, reactivity, Resumption, routing and testing. Each rule shows if it is forced or chosen. A forced rule changes only for a new fact. A chosen rule changes for a better trade.

  41. Six questions stay open, but nobody guesses at them. Each open question records what it blocks, and none of them blocks the demo application. Navigation on the client is one of these questions. v0 loads the full page for each navigation, and the specification says so.

  42. Two routing questions are closed. First: a Route cannot set the status code after the stream starts, because the server already sent the headers. Second: each Route has one Canonical URL. That URL carries no trailing slash, and ursprung redirects a request that uses a different form.

  43. The Server boundary is the point where a Client module imports from a Server module. Its rules are complete. Each Callable export is a public endpoint, because any person who opens the page can call it. What the author intended does not change this. The RPC model starts from that fact.

  44. The format of the Resumability payload is decided, and Server rendering streams it with the HTML. The client reads what the server sent. The client does not walk the component tree a second time to find the positions again. ursprung calls this Resumption, and it is not hydration.

  45. The site has an about page and a glossary. The glossary is a copy of CONTEXT.md, which holds the project's ubiquitous language. A person copies each change by hand. If you change a term in that file and do not copy it, the site shows the old language.

  46. moveBefore is a browser function that moves an element and keeps its state. v0 first assumed that every browser has it. That decision changed seven minutes later, because Safari does not. The Host DOM Host keeps a fallback for it. A Host is written to and never read from.

  47. The argument about the update strategy stopped making progress. A prototype then measured three strategies in real Chromium. The result: ursprung has no virtual DOM. A change flows to the one Live position that reads the Signal.

  48. The site has a set of favicons, cut from the mark in the app icon. The shape is the same, but the weights are different. At 16 pixels a ring thinner than one pixel disappears. The rasteriser and the PNG encoder are part of this repository, not a dependency.

  49. The name ursprung is lowercase in all text: prose, headings, titles and commit messages. One pass corrected the map, the glossary, the decision records and the posts.

  50. The build makes one graph for both Sides, not a pair of bundles. Each first-party module declares its Side in its filename. The Side of a module tells the build where that module can run. After emission, the build audits what it wrote.

  51. ursprung.dev serves static assets now, and it has a list of posts. Tailwind makes the stylesheet through its standalone command-line tool. That stylesheet is the only generated file on the site.

  52. v0 started as a map of twenty-four open decisions, not as a list of tasks. Research answered four of those decisions before any code existed. Two of the four answers changed the plan.

  53. The Worker is live on ursprung.dev. Workers Builds deploys it when a commit goes to the main branch. Its configuration is TypeScript, not JSON. This Wrangler format is experimental, and it rejects a key that you spell incorrectly.

  54. The ursprung package is on npm, with provenance. GitHub OIDC supplies the credential, so the repository holds no token. The package exports only its own name and version. The goal was to prove the release path, not to release a framework.

  55. The first commit that does work: a Bun monorepo with one package and one Worker. There is no build step between them. The package supplies TypeScript source directly to the tool that imports it.