Ver Fonte

feat(web): unify served and preview startup behind a boot-ready seam

The webserver renders a boot-readiness tail after the injection rows and
AppWebEntry.run awaits the __DSH_BOOT_READY__ deferred before reading any
injected state. Whichever bootstrap applies the injection table settles
the deferred - the served renderer resolves it inline, an asynchronous
bootstrap installs it ahead of the entry module and settles it with the
handshake - so both deployments run one startup chain and a failed
handshake surfaces on the boot page instead of proceeding on missing
globals.
imccyu há 1 mês atrás
pai
commit
fd3112a23f

+ 7 - 0
packages/client/web/src/boot.ts

@@ -45,6 +45,13 @@ export class AppWebEntry {
    */
   async run(): Promise<void> {
     try {
+      // Boot-readiness gate: whichever bootstrap applies the injection table
+      // settles this deferred once every row has taken effect — the served
+      // index resolves it in the rendered tail, so the await returns on the
+      // next microtask; an asynchronous bootstrap resolves it after its last
+      // row, or rejects it into the failure rendering below. An absent global
+      // means no bootstrap owns the document and there is nothing to wait for.
+      await (globalThis as { __DSH_BOOT_READY__?: { promise: Promise<void> } }).__DSH_BOOT_READY__?.promise
       const win = globalThis as DshWindow
       const moduleLoader = win.__ModuleLoader__
       if (moduleLoader === undefined) {

+ 12 - 1
packages/host/webserver/src/injections.ts

@@ -71,10 +71,20 @@ function splice(html: string, at: number, markup: string): string {
   return `${html.slice(0, at)}${markup}${html.slice(at)}`
 }
 
+/**
+ * Tail script settling the boot-readiness deferred (`__DSH_BOOT_READY__`):
+ * the client entry awaits its `.promise` before reading any injected state.
+ * Whichever side runs first creates the deferred (`??=`), so a bootstrap that
+ * applies the table asynchronously installs it ahead of the entry module and
+ * settles it after the last row; the served form below creates and resolves
+ * it in one statement, because every row is already in the document text.
+ */
+const READY_MARKUP = '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>'
+
 /**
  * Render rows into an index.html body: head rows immediately after the
  * opening head tag, body rows immediately after the opening body tag, each
- * group in table order.
+ * group in table order, and the boot-readiness tail after the last body row.
  * @param html - the raw index.html body.
  * @param rows - the collected injection table.
  * @returns the html with every row rendered.
@@ -87,6 +97,7 @@ export function renderIndexInjections(html: string, rows: readonly IndexInjectio
     if (rendered.placement === 'head') head += rendered.markup
     else body += rendered.markup
   }
+  body += READY_MARKUP
   let out = html
   if (head !== '') {
     const open = /<head(?:\s[^>]*)?>/i.exec(out)

+ 4 - 2
packages/host/webserver/tests/webserver.spec.ts

@@ -241,11 +241,13 @@ describe('real Loader composition', () => {
     expect(server.renderIndex('<head></head><body></body>')).toContain('window.__Q__=2')
     untap()
 
-    // Tag-less fragments: head rows prepend, body rows append.
+    // Tag-less fragments: head rows prepend, body rows append, and the
+    // boot-readiness tail lands after the last body row.
     expect(renderIndexInjections('<main>x</main>', [
       { kind: 'script', placement: 'head', text: 'H' },
       { kind: 'script', placement: 'body', text: 'B' },
-    ])).toBe('<script>H</script><main>x</main><script>B</script>')
+    ])).toBe('<script>H</script><main>x</main><script>B</script>'
+      + '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>')
   })
 
   it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {