@webjsdev/server 0.8.40 → 0.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -77,4 +77,5 @@ export {
77
77
  actionEndpoint,
78
78
  invokeActionForTest,
79
79
  rawActionRequest,
80
+ createBrowserTestHandler,
80
81
  } from './src/testing.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.40",
3
+ "version": "0.8.41",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/check.js CHANGED
@@ -82,7 +82,7 @@ export const RULES = [
82
82
  {
83
83
  name: 'reactive-props-no-class-field',
84
84
  description:
85
- 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
85
+ 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field declaration (`count = 0`, `count: number = 0`, `count!: number`, or `count?: number`) in the class body. Under modern class-field semantics (including `erasableSyntaxOnly: true`) every class-field declaration compiles to Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
86
86
  },
87
87
  {
88
88
  name: 'array-prop-uses-array-type',
@@ -134,6 +134,11 @@ export const RULES = [
134
134
  description:
135
135
  'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
136
136
  },
137
+ {
138
+ name: 'no-redirect-in-api-route',
139
+ description:
140
+ 'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
141
+ },
137
142
  ];
138
143
 
139
144
  /** Set of all known rule names for fast lookup. */
@@ -268,12 +273,20 @@ function findFieldInitializers(classBody, props) {
268
273
  let j = lineStart;
269
274
  while (j < classBody.length && classBody[j] !== '\n') j++;
270
275
  const line = classBody.slice(lineStart, j);
271
- // Match: optional whitespace, optional `public/private/protected/readonly`,
272
- // an identifier, optional `: <type>`, then `=`.
273
- const fieldRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
274
- const m = fieldRe.exec(line);
275
- if (m) {
276
- const name = m[2];
276
+ // Match class-field declarations. Two shapes:
277
+ // 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
278
+ // 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
279
+ // Both compile to Object.defineProperty after super() under modern class-field
280
+ // semantics, clobbering the reactive accessor.
281
+ // The initializer regex: optional modifier, identifier, optional type, then `=`.
282
+ const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
283
+ // The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
284
+ const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
285
+ const initM = initRe.exec(line);
286
+ const typeM = typeOnlyRe.exec(line);
287
+ // Prefer the initializer match; if neither, skip.
288
+ const name = initM ? initM[2] : (typeM ? typeM[2] : null);
289
+ if (name) {
277
290
  // `declare`, `static`, and `this.` patterns shouldn't reach here
278
291
  // (declare/static start with their keyword, this.x has the dot in
279
292
  // the regex group), but guard against matching keywords as names:
@@ -459,8 +472,11 @@ export async function checkConventions(appDir) {
459
472
 
460
473
  // --- Rule: reactive-props-no-class-field ---
461
474
  // A reactive property declared via the factory must not also carry a plain
462
- // class-field initializer: it runs Object.defineProperty after super() and
463
- // clobbers the framework's reactive accessor (silent broken re-renders).
475
+ // class-field declaration (initializer OR type-only): under modern class-field
476
+ // semantics (including `erasableSyntaxOnly: true`) every class-field declaration
477
+ // compiles to Object.defineProperty after super() and clobbers the framework's
478
+ // reactive accessor (silent broken re-renders). Catches `count = 0`,
479
+ // `count: number = 0`, `count!: number`, and `count?: number`.
464
480
  {
465
481
  for (const { rel, scan } of files) {
466
482
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
@@ -470,8 +486,8 @@ export async function checkConventions(appDir) {
470
486
  violations.push({
471
487
  rule: 'reactive-props-no-class-field',
472
488
  file: rel,
473
- message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
474
- fix: `Remove the class-field initializer and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
489
+ message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
490
+ fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
475
491
  });
476
492
  }
477
493
  }
@@ -557,6 +573,67 @@ export async function checkConventions(appDir) {
557
573
  }
558
574
  }
559
575
 
576
+ // --- Rule: no-redirect-in-api-route ---
577
+ // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
578
+ // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
579
+ // and produces a 500. API handlers must use `Response.redirect(url, 303)`
580
+ // instead. Page functions, layouts, and server actions may still use
581
+ // `redirect()` (caught by the SSR / action pipeline).
582
+ {
583
+ const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
584
+ for (const { rel, scan } of files) {
585
+ if (!ROUTE_FILE.test(rel)) continue;
586
+ // `redirect` reaches the route file in one of two statically-visible ways:
587
+ // 1. A NAMED import from `@webjsdev/core` (possibly aliased):
588
+ // `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
589
+ // 2. A NAMESPACE import then a member call:
590
+ // `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
591
+ // The named case flags a bare `redirect(` call; the namespace case flags
592
+ // `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
593
+ // the standard API and stay fine. A `redirect()` thrown inside a
594
+ // '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
595
+ // cross-file analysis and is left to the AST rework (#753).
596
+ const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
597
+ const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
598
+ // A file can carry BOTH a named `redirect` import AND a namespace import,
599
+ // so check every matcher independently (not mutually exclusive): a named
600
+ // import means a bare `<localName>(`, a namespace import means
601
+ // `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
602
+ // named call can screen out `Response.redirect(` / `obj.redirect(`.
603
+ /** @type {Array<{ re: RegExp, member: boolean }>} */
604
+ const matchers = [];
605
+ if (namedM) {
606
+ const localName = namedM[1] || 'redirect';
607
+ matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
608
+ }
609
+ if (nsM) {
610
+ matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
611
+ }
612
+ let flagged = false;
613
+ for (const { re, member } of matchers) {
614
+ if (flagged) break;
615
+ let m;
616
+ while ((m = re.exec(scan)) !== null) {
617
+ if (!member) {
618
+ // Screen out `Response.redirect(` / `someObj.redirect(` sharing the
619
+ // local name: a preceding member-access dot means it is not the import.
620
+ const before = scan.slice(Math.max(0, m.index - 20), m.index);
621
+ if (/\w\.$/.test(before)) continue;
622
+ }
623
+ violations.push({
624
+ rule: 'no-redirect-in-api-route',
625
+ file: rel,
626
+ message:
627
+ `\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
628
+ fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
629
+ });
630
+ flagged = true; // one violation per file is enough
631
+ break;
632
+ }
633
+ }
634
+ }
635
+ }
636
+
560
637
  // --- Rule: no-server-env-in-components ---
561
638
  // Catches `process.env.X` reads in component files where X is not a
562
639
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
@@ -126,6 +126,35 @@ export const CLIENT_LIFECYCLE_HOOKS = [
126
126
  */
127
127
  export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
128
128
 
129
+ /**
130
+ * Static class fields whose declaration (to a non-`false` value) marks a
131
+ * component interactive (must ship). Kept as ONE registry so a new
132
+ * interactivity-relevant static convention is added in a single place; the
133
+ * per-class analysis loops over it. Unlike prototype methods (guarded by
134
+ * `lifecycle-coverage.test.js` via prototype introspection), there is no
135
+ * enumerable runtime source of "all static conventions", so the contract is
136
+ * a documented one: a new interactivity static field MUST be added here AND
137
+ * to the lifecycle table in agent-docs/components.md. `sigil-coverage.test.js`
138
+ * asserts each entry is honoured as a ship signal.
139
+ *
140
+ * - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
141
+ * component that arrives via a client DOM insertion (a soft-nav swap, a
142
+ * streamed <webjs-suspense> boundary's replaceWith) would never re-attach
143
+ * its shadow root if elided. Context-free, so any shadow component ships.
144
+ * - `refresh`: `static refresh = true` is the explicit opt-in to ship a bare
145
+ * async component so its stale-while-revalidate refresh runs after SSR;
146
+ * eliding drops that on-load re-fetch (moot for request-stable data).
147
+ *
148
+ * @type {readonly string[]}
149
+ */
150
+ export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'refresh'];
151
+
152
+ /** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
153
+ const STATIC_FIELD_REASONS = {
154
+ shadow: 'declares static shadow (DSD must re-attach on a client swap)',
155
+ refresh: 'declares static refresh = true (keeps the on-load re-fetch)',
156
+ };
157
+
129
158
  /**
130
159
  * A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
131
160
  * pass bakes the resolved data into the first paint, so a light-DOM async
@@ -144,8 +173,37 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
144
173
  * re-fetch that eliding would drop).
145
174
  */
146
175
 
147
- /** Match a `@event=${...}` binding inside a template (unquoted per invariant 4). */
148
- const EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/;
176
+ /**
177
+ * Template binding-prefix classification, the anchor for the elision drift
178
+ * guard. Every prefix core's renderers recognise (core's `BINDING_PREFIXES`)
179
+ * is one of two kinds:
180
+ * - SSR_DROPPED_PREFIXES: a CLIENT-BEHAVIOUR ship signal. The binding drops
181
+ * at SSR and is wired only after hydration (`@event`), so a component whose
182
+ * only interactivity is such a binding MUST ship.
183
+ * - ROUND_TRIP_PREFIXES: an SSR-SAFE binding that survives into the served
184
+ * HTML (`.prop` via `data-webjs-prop-*`, `?bool` as a boolean attribute),
185
+ * so it is NOT a ship signal on its own and stays elidable.
186
+ * `sigil-coverage.test.js` asserts these two lists PARTITION `BINDING_PREFIXES`
187
+ * exactly (union equal, disjoint), so a new sigil cannot be added to the
188
+ * renderers without being classified here. That closes the one interactivity
189
+ * surface the prototype-introspection guard (`lifecycle-coverage.test.js`)
190
+ * cannot see, because a sigil is template syntax, not a method or an export.
191
+ */
192
+ export const SSR_DROPPED_PREFIXES = ['@'];
193
+ export const ROUND_TRIP_PREFIXES = ['.', '?'];
194
+
195
+ /** Escape a single char for safe inclusion in a regex character class. */
196
+ const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
197
+
198
+ /**
199
+ * Match a client-behaviour binding (`@event=${...}`), a ship signal. The
200
+ * prefix set is DERIVED from SSR_DROPPED_PREFIXES (not hardcoded), so a new
201
+ * client-behaviour sigil added to core is detected here automatically once it
202
+ * is classified above. (unquoted per invariant 4)
203
+ */
204
+ const EVENT_BINDING_RE = new RegExp(
205
+ `[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*\\$\\{`,
206
+ );
149
207
 
150
208
  /** Match a `.onclick=${...}` (native event-handler property) binding. */
151
209
  const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
@@ -505,22 +563,13 @@ export function analyzeComponentSource(src) {
505
563
  }
506
564
 
507
565
  for (const { body, factoryArg } of bodies) {
508
- // Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
509
- // during HTML parsing; an elided component that arrives via a client DOM
510
- // insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
511
- // replaceWith) would never re-attach its shadow root, because eliding
512
- // drops the module that runs `attachShadow`. The analyser is context-free
513
- // (it cannot tell whether a given component will be streamed or
514
- // navigated to), so any shadow component is shipped.
515
- if (declaresStaticTrue(body, 'shadow')) {
516
- return { interactive: true, reason: 'declares static shadow (DSD must re-attach on a client swap)' };
517
- }
518
- // `static refresh = true` is the explicit opt-in to ship a bare async
519
- // component so its stale-while-revalidate refresh runs ~after SSR; eliding
520
- // a bare async component drops that on-load re-fetch (moot for request-
521
- // stable data, the default), so an author who wants fresh-on-load opts in.
522
- if (declaresStaticTrue(body, 'refresh')) {
523
- return { interactive: true, reason: 'declares static refresh = true (keeps the on-load re-fetch)' };
566
+ // Interactivity-signal static conventions (`static shadow` / `static
567
+ // refresh = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry so a
568
+ // new convention is added in one place (see its doc for why each ships).
569
+ for (const field of INTERACTIVITY_STATIC_FIELDS) {
570
+ if (declaresStaticTrue(body, field)) {
571
+ return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
572
+ }
524
573
  }
525
574
  for (const hook of CLIENT_LIFECYCLE_HOOKS) {
526
575
  // A client lifecycle hook as a method (`hook(`) OR as an arrow class
package/src/dev.js CHANGED
@@ -686,6 +686,14 @@ export async function createRequestHandler(opts) {
686
686
  middleware: null,
687
687
  bodyLimits,
688
688
  logger,
689
+ // Test-mode serve gate (#806). Set ONLY by the browser-test harness
690
+ // (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
691
+ // `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
692
+ // so ANY app file under appDir is servable (a component a browser test
693
+ // imports is not route-reachable, so it is absent from `browserBoundFiles`
694
+ // and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
695
+ // the core / vendor serving are unchanged, so no server source is exposed.
696
+ testMode: opts.testMode === true,
689
697
  moduleGraph: null,
690
698
  elidableComponents: new Set(),
691
699
  inertRouteModules: new Set(),
@@ -1864,8 +1872,16 @@ async function handleCore(req, ctx) {
1864
1872
  // files (.server.{js,ts}) get a stub via the guardrail below; they
1865
1873
  // ARE included in browserBoundFiles because client code imports
1866
1874
  // them by path (the import rewrites to an RPC stub at request time).
1867
- const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
1868
- if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
1875
+ // In test mode any app file is servable (see the `state.testMode` note
1876
+ // above); otherwise the file must be in the browser-bound module graph.
1877
+ const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
1878
+ // Containment: `abs` must be appDir itself or genuinely UNDER it. The
1879
+ // trailing `sep` (matching the public-asset branch) stops a `..` path that
1880
+ // resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
1881
+ // `/x/app-secrets/...`) from passing in test mode, where graph membership
1882
+ // is not the gate.
1883
+ const underAppDir = abs === appDir || abs.startsWith(appDir + sep);
1884
+ if (underAppDir && inGraph && (await exists(abs))) {
1869
1885
  // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
1870
1886
  // MUST NEVER be served as source to the browser. The extension is
1871
1887
  // the path-level boundary; we re-verify it on every request (not
@@ -447,6 +447,24 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
447
447
  // position is blanked in the mask, the match lives inside a literal
448
448
  // and is not a real import edge.
449
449
  if (masked[m.index] === ' ') continue;
450
+ // A statement-leading `import type { X }` / `import type * as X` /
451
+ // `import type Foo` (default type import) / `export type { X }` /
452
+ // `export type *` is fully erased by the TS stripper, so it never becomes
453
+ // a browser fetch or a runtime edge. Skip it, so a type-only import of a
454
+ // `.server.ts` (a common, correct way to share a derived row type) is not
455
+ // mistaken for a runtime dependency by the gate / elision / the
456
+ // no-server-import check. Two carve-outs keep this from over-dropping:
457
+ // - `import type from '...'` is a VALUE default import whose binding is
458
+ // literally named `type` (the stripper keeps it), so `type` directly
459
+ // followed by `from` is NOT type-only.
460
+ // - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
461
+ // kept (its `b` binding is a real runtime edge).
462
+ // #805
463
+ const lead = m[0];
464
+ const typeOnly =
465
+ /^(?:import|export)\s+type\s*[{*]/.test(lead) ||
466
+ (/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
467
+ if (typeOnly) continue;
450
468
  const spec = m[1];
451
469
  // Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
452
470
  // EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
package/src/testing.d.ts CHANGED
@@ -103,3 +103,13 @@ export declare function rawActionRequest(
103
103
  contentType?: string;
104
104
  },
105
105
  ): Promise<Response>;
106
+
107
+ /** Browser-test harness (#806): a webjs handler a web-test-runner config
108
+ * proxies module requests to, so a browser test can import a real `.ts`
109
+ * component that imports a `'use server'` action. `importmapHtml()` is the
110
+ * `<script type="importmap">` to inject into the test page (call after warmup). */
111
+ export declare function createBrowserTestHandler(appDir: string): Promise<{
112
+ handle: Handle;
113
+ warmup: () => Promise<void>;
114
+ importmapHtml: () => string;
115
+ }>;
package/src/testing.js CHANGED
@@ -348,3 +348,38 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
348
348
 
349
349
  return testRequest(handle, endpoint, { method: 'POST', headers, body });
350
350
  }
351
+
352
+ /**
353
+ * Browser-test harness (#806): build a webjs handler that a `web-test-runner`
354
+ * config can proxy module requests to, so a `test/**​/browser/*.test.js` file
355
+ * can import a real `.ts` component that imports a `'use server'` action and it
356
+ * loads + hydrates in a real browser. Plain WTR serves raw TS with no webjs
357
+ * transform, so the component (and its `#`-alias / `.server.ts` imports) never
358
+ * resolves; this handler runs the SAME dev pipeline (TS strip, `.server.ts` ->
359
+ * RPC stub, `#` alias, `/__webjs/core/*`, the importmap) as `webjs dev`.
360
+ *
361
+ * Uses `testMode` so ANY app file under appDir is servable (a component a test
362
+ * imports is not route-reachable, so it is absent from the browser-bound graph
363
+ * the normal serve gate requires). The `.server.*` source guardrail is
364
+ * unchanged, so no server source is exposed. `importmapHtml()` returns the
365
+ * `<script type="importmap">` to inject into the test page (call after warmup,
366
+ * so the map is final).
367
+ *
368
+ * The dev handler + importmap builder are LAZILY imported so importing the rest
369
+ * of `@webjsdev/server/testing` stays light (they pull the full server + the
370
+ * `ws` WebSocket subsystem).
371
+ *
372
+ * @param {string} appDir the webjs app root (usually `process.cwd()`)
373
+ * @returns {Promise<{ handle: Handle, warmup: () => Promise<void>, importmapHtml: () => string }>}
374
+ */
375
+ export async function createBrowserTestHandler(appDir) {
376
+ const { createRequestHandler } = await import('./dev.js');
377
+ const { importMapTag } = await import('./importmap.js');
378
+ const app = await createRequestHandler({ appDir, dev: true, testMode: true });
379
+ if (app.warmup) await app.warmup();
380
+ return {
381
+ handle: app.handle,
382
+ warmup: app.warmup || (async () => {}),
383
+ importmapHtml: () => importMapTag(),
384
+ };
385
+ }