@gmickel/gno 1.36.0 → 1.37.0

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.
@@ -12,8 +12,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
12
12
  import { resolveDepthPolicy } from "../../../core/depth-policy";
13
13
  import { normalizeStructuredQueryInput } from "../../../core/structured-query";
14
14
  import { Loader } from "../components/ai-elements/loader";
15
- import { AIModelSelector } from "../components/AIModelSelector";
16
- import { TagFacets } from "../components/TagFacets";
17
15
  import {
18
16
  ThoroughnessSelector,
19
17
  type Thoroughness,
@@ -47,6 +45,7 @@ import {
47
45
  type TagMode,
48
46
  } from "../lib/retrieval-filters";
49
47
  import { cn } from "../lib/utils";
48
+ import { AIModelSelector, TagFacets } from "./search-page-widgets";
50
49
 
51
50
  /**
52
51
  * Render snippet with <mark> tags as highlighted spans.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * DocView-only re-export of FrontmatterDisplay.
3
+ *
4
+ * Kept as a separate module so DOM tests can mock.module this path without
5
+ * sticky-mock pollution of the real FrontmatterDisplay suite.
6
+ */
7
+ export {
8
+ FrontmatterDisplay,
9
+ parseFrontmatter,
10
+ } from "../components/FrontmatterDisplay";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Search-only re-exports of sidebar widgets.
3
+ *
4
+ * Kept as a separate module so DOM tests can mock.module this path without
5
+ * sticky-mock pollution of the real TagFacets / AIModelSelector suites.
6
+ */
7
+ export { AIModelSelector } from "../components/AIModelSelector";
8
+ export { TagFacets } from "../components/TagFacets";
@@ -304,16 +304,16 @@ export async function startServer(
304
304
 
305
305
  // Start server with try/catch for port-in-use etc.
306
306
  let server: ReturnType<typeof Bun.serve>;
307
- // Bun HTMLBundle route values cannot carry custom headers. In production,
308
- // host it on a private Unix socket (ephemeral loopback on Windows), then
309
- // proxy bytes through this public listener's security envelope. Injected
310
- // server tests keep an in-listener bundle route because their fake Bun
311
- // server cannot host the private source.
307
+ // Bun HTMLBundle / built-chunk route values cannot carry custom headers.
308
+ // Host them on a private Unix socket (ephemeral loopback on Windows), then
309
+ // proxy bytes through this public listener's security envelope. Production
310
+ // emits split JS chunks; injected-server tests keep an in-listener bundle
311
+ // route because their fake Bun server cannot host the private source.
312
312
  let spaBundleSource: SpaBundleSource | null = null;
313
313
  const usesInjectedServer = dependencies.serve !== undefined;
314
314
  try {
315
315
  if (!usesInjectedServer) {
316
- spaBundleSource = createSpaBundleSource(homepage, isDev);
316
+ spaBundleSource = await createSpaBundleSource(homepage, isDev);
317
317
  }
318
318
  } catch (error) {
319
319
  removeShutdownHandlers();
@@ -1,12 +1,17 @@
1
1
  import type { HTMLBundle, Server } from "bun";
2
2
 
3
- // node:fs/promises — no Bun equivalent for removing a Unix socket pathname.
3
+ // node:fs/promises — no Bun equivalent for unlink of private sockets.
4
4
  import { unlink } from "node:fs/promises";
5
5
  // node:os — no Bun equivalent for the platform temporary directory.
6
6
  import { tmpdir } from "node:os";
7
- // node:path — no Bun equivalent for joining the socket path.
7
+ // node:path — no Bun path utils.
8
8
  import { join } from "node:path";
9
9
 
10
+ import {
11
+ getProductionSpaAssets,
12
+ type ProductionSpaAssets,
13
+ } from "./spa-production";
14
+
10
15
  type BunServer = Server<unknown>;
11
16
 
12
17
  export type SpaBundleSource = {
@@ -17,33 +22,109 @@ export type SpaBundleSource = {
17
22
 
18
23
  const notFound = (): Response => new Response("Not Found", { status: 404 });
19
24
 
25
+ const isEnoent = (error: unknown): boolean =>
26
+ Boolean(
27
+ error &&
28
+ typeof error === "object" &&
29
+ "code" in error &&
30
+ error.code === "ENOENT"
31
+ );
32
+
20
33
  /**
21
- * Host Bun's headerless HTMLBundle surface outside the public listener.
34
+ * Host Bun's headerless HTML / asset surface outside the public listener.
22
35
  *
23
36
  * Unix hosts use a private Unix-domain socket, so the raw bundle and generated
24
37
  * assets have no TCP origin at all. Windows falls back to an ephemeral
25
38
  * loopback listener plus an unguessable entry path. The public server proxies
26
39
  * the bytes and applies its normal security envelope before browser delivery.
40
+ *
41
+ * Production (`isDev === false`) serves the split SPA snapshot embedded from
42
+ * `assets/spa-production.json.gz`. Source and compiled executables share that
43
+ * path so first listen does not wait on `Bun.build`. Compiled binaries also
44
+ * cannot call `Bun.build` on `/$bunfs` (ENOENT on the virtual root).
45
+ * Development keeps the live HTMLBundle so HMR still works. Refresh the
46
+ * snapshot with `bun scripts/build-spa-production.ts`.
27
47
  */
28
- export function createSpaBundleSource(
48
+ export async function createSpaBundleSource(
29
49
  bundle: HTMLBundle,
30
50
  isDev: boolean
31
- ): SpaBundleSource {
51
+ ): Promise<SpaBundleSource> {
32
52
  const nonce = crypto.randomUUID().replaceAll("-", "");
33
53
  const entryPath = `/__gno_spa_${nonce}`;
54
+
55
+ if (isDev) {
56
+ return hostPrivateSource({
57
+ entryPath,
58
+ isDev: true,
59
+ nonce,
60
+ routes: { [entryPath]: bundle },
61
+ });
62
+ }
63
+
64
+ return createSplitProductionSource(entryPath, nonce);
65
+ }
66
+
67
+ async function createSplitProductionSource(
68
+ entryPath: string,
69
+ nonce: string
70
+ ): Promise<SpaBundleSource> {
71
+ const assets = await getProductionSpaAssets();
72
+ return hostProductionAssets(assets, entryPath, nonce);
73
+ }
74
+
75
+ async function hostProductionAssets(
76
+ assets: ProductionSpaAssets,
77
+ entryPath: string,
78
+ nonce: string
79
+ ): Promise<SpaBundleSource> {
80
+ const resolve = (pathname: string): Response => {
81
+ if (pathname === entryPath) {
82
+ return new Response(assets.html, {
83
+ headers: { "Content-Type": "text/html;charset=utf-8" },
84
+ });
85
+ }
86
+ const file = assets.files[pathname];
87
+ if (!file) {
88
+ return notFound();
89
+ }
90
+ return new Response(file.text, {
91
+ headers: { "Content-Type": file.type },
92
+ });
93
+ };
94
+
95
+ return hostPrivateSource({
96
+ entryPath,
97
+ fetchAsset: resolve,
98
+ isDev: false,
99
+ nonce,
100
+ });
101
+ }
102
+
103
+ async function hostPrivateSource(options: {
104
+ entryPath: string;
105
+ fetchAsset?: (pathname: string) => Response;
106
+ isDev: boolean;
107
+ nonce: string;
108
+ routes?: Record<string, HTMLBundle>;
109
+ }): Promise<SpaBundleSource> {
34
110
  let server: BunServer;
35
111
  let fetchPrivate: (request: Request) => Promise<Response>;
36
112
  let socketPath: string | null = null;
37
-
38
- const routes = { [entryPath]: bundle };
39
- const fallback = { fetch: notFound };
113
+ const fallback = {
114
+ fetch: (request: Request): Response => {
115
+ if (!options.fetchAsset) {
116
+ return notFound();
117
+ }
118
+ return options.fetchAsset(new URL(request.url).pathname);
119
+ },
120
+ };
40
121
 
41
122
  if (process.platform === "win32") {
42
123
  server = Bun.serve({
124
+ development: options.isDev,
43
125
  hostname: "127.0.0.1",
44
126
  port: 0,
45
- development: isDev,
46
- routes,
127
+ routes: options.routes,
47
128
  ...fallback,
48
129
  });
49
130
  const origin = `http://127.0.0.1:${server.port}`;
@@ -54,11 +135,11 @@ export function createSpaBundleSource(
54
135
  });
55
136
  };
56
137
  } else {
57
- socketPath = join(tmpdir(), `gno-spa-${nonce.slice(0, 20)}.sock`);
138
+ socketPath = join(tmpdir(), `gno-spa-${options.nonce.slice(0, 20)}.sock`);
58
139
  server = Bun.serve({
140
+ development: options.isDev,
141
+ routes: options.routes,
59
142
  unix: socketPath,
60
- development: isDev,
61
- routes,
62
143
  ...fallback,
63
144
  });
64
145
  fetchPrivate = async (request): Promise<Response> => {
@@ -72,9 +153,7 @@ export function createSpaBundleSource(
72
153
 
73
154
  let closed = false;
74
155
  return {
75
- entryPath,
76
- fetch: fetchPrivate,
77
- async close(): Promise<void> {
156
+ close: async (): Promise<void> => {
78
157
  if (closed) {
79
158
  return;
80
159
  }
@@ -84,16 +163,13 @@ export function createSpaBundleSource(
84
163
  try {
85
164
  await unlink(socketPath);
86
165
  } catch (error) {
87
- if (
88
- !error ||
89
- typeof error !== "object" ||
90
- !("code" in error) ||
91
- error.code !== "ENOENT"
92
- ) {
166
+ if (!isEnoent(error)) {
93
167
  throw error;
94
168
  }
95
169
  }
96
170
  }
97
171
  },
172
+ entryPath: options.entryPath,
173
+ fetch: fetchPrivate,
98
174
  };
99
175
  }
@@ -0,0 +1,130 @@
1
+ // node:fs/promises — no Bun equivalent for mkdir/rm of the temporary SPA outdir.
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ // node:os — no Bun equivalent for the platform temporary directory.
4
+ import { tmpdir } from "node:os";
5
+ // node:path — no Bun path utils.
6
+ import { basename, join } from "node:path";
7
+
8
+ export const ROOT_MOUNT_MARKER = 'getElementById("root")';
9
+
10
+ export type ProductionSpaFile = {
11
+ text: string;
12
+ type: string;
13
+ };
14
+
15
+ export type ProductionSpaAssets = {
16
+ files: Record<string, ProductionSpaFile>;
17
+ html: string;
18
+ };
19
+
20
+ const SCRIPT_TAG_RE = /<script\b[^>]*\bsrc="[^"]+"[^>]*><\/script>/iu;
21
+ const BASE_TAG_RE = /<base\b[^>]*>/iu;
22
+ const CROSSORIGIN_ATTR_RE = /\s+crossorigin(?:=(?:"[^"]*"|'[^']*'))?/gu;
23
+
24
+ const contentTypeFor = (path: string): string => {
25
+ if (path.endsWith(".css")) {
26
+ return "text/css;charset=utf-8";
27
+ }
28
+ if (path.endsWith(".js")) {
29
+ return "text/javascript;charset=utf-8";
30
+ }
31
+ if (path.endsWith(".map")) {
32
+ return "application/json";
33
+ }
34
+ return "application/octet-stream";
35
+ };
36
+
37
+ export const isStandaloneExecutable = (): boolean =>
38
+ import.meta.path.includes("/$bunfs/") ||
39
+ import.meta.path.includes("\\$bunfs\\");
40
+
41
+ export const isBunfsPath = (path: string): boolean =>
42
+ path.includes("/$bunfs/") || path.includes("\\$bunfs\\");
43
+
44
+ export const productionSpaEntryPath = (): string =>
45
+ join(import.meta.dir, "public", "index.html");
46
+
47
+ const rewriteProductionHtml = (html: string, jsEntryPath: string): string => {
48
+ const script = `<script type="module" src="/${basename(jsEntryPath)}"></script>`;
49
+ let next = html.replace(BASE_TAG_RE, "");
50
+ if (SCRIPT_TAG_RE.test(next)) {
51
+ next = next.replace(SCRIPT_TAG_RE, script);
52
+ } else {
53
+ throw new Error("Production SPA HTML is missing a module script tag");
54
+ }
55
+ return next.replace(CROSSORIGIN_ATTR_RE, "");
56
+ };
57
+
58
+ export const buildProductionSpaAssets = async (
59
+ entryPath: string = productionSpaEntryPath()
60
+ ): Promise<ProductionSpaAssets> => {
61
+ if (isBunfsPath(entryPath)) {
62
+ throw new Error(
63
+ `Cannot Bun.build a production SPA from bunfs (${entryPath})`
64
+ );
65
+ }
66
+
67
+ const outdir = join(
68
+ tmpdir(),
69
+ `gno-spa-build-${crypto.randomUUID().replaceAll("-", "").slice(0, 20)}`
70
+ );
71
+ await mkdir(outdir, { recursive: true });
72
+
73
+ let result: Awaited<ReturnType<typeof Bun.build>>;
74
+ try {
75
+ result = await Bun.build({
76
+ entrypoints: [entryPath],
77
+ minify: true,
78
+ outdir,
79
+ publicPath: "/",
80
+ splitting: true,
81
+ target: "browser",
82
+ });
83
+ } catch (error) {
84
+ await rm(outdir, { recursive: true, force: true });
85
+ throw error;
86
+ }
87
+
88
+ if (!result.success) {
89
+ await rm(outdir, { recursive: true, force: true });
90
+ throw new Error("Production SPA split build failed");
91
+ }
92
+
93
+ try {
94
+ const htmlArtifact = result.outputs.find((output) =>
95
+ output.path.endsWith(".html")
96
+ );
97
+ const jsEntry = result.outputs.find(
98
+ (output) => output.kind === "entry-point" && output.path.endsWith(".js")
99
+ );
100
+ if (!htmlArtifact || !jsEntry) {
101
+ throw new Error(
102
+ "Production SPA split build did not emit HTML and JS entry"
103
+ );
104
+ }
105
+
106
+ const jsEntryText = await jsEntry.text();
107
+ if (!jsEntryText.includes(ROOT_MOUNT_MARKER)) {
108
+ throw new Error(
109
+ "Production SPA JS entry does not mount #root; HTML would leave the shell blank"
110
+ );
111
+ }
112
+
113
+ const html = rewriteProductionHtml(await htmlArtifact.text(), jsEntry.path);
114
+ const files: Record<string, ProductionSpaFile> = {};
115
+ for (const output of result.outputs) {
116
+ if (output.path.endsWith(".html")) {
117
+ continue;
118
+ }
119
+ const publicPath = `/${basename(output.path)}`;
120
+ files[publicPath] = {
121
+ text: await output.text(),
122
+ type: contentTypeFor(output.path),
123
+ };
124
+ }
125
+
126
+ return { files, html };
127
+ } finally {
128
+ await rm(outdir, { recursive: true, force: true });
129
+ }
130
+ };
@@ -0,0 +1,43 @@
1
+ import productionSpaGzip from "../../assets/spa-production.json.gz" with { type: "file" };
2
+ import {
3
+ ROOT_MOUNT_MARKER,
4
+ type ProductionSpaAssets,
5
+ } from "./spa-production-build";
6
+
7
+ export {
8
+ buildProductionSpaAssets,
9
+ isBunfsPath,
10
+ isStandaloneExecutable,
11
+ productionSpaEntryPath,
12
+ ROOT_MOUNT_MARKER,
13
+ type ProductionSpaAssets,
14
+ type ProductionSpaFile,
15
+ } from "./spa-production-build";
16
+
17
+ export const loadEmbeddedProductionSpa =
18
+ async (): Promise<ProductionSpaAssets> => {
19
+ const compressed = await Bun.file(productionSpaGzip).arrayBuffer();
20
+ const json = new TextDecoder().decode(
21
+ Bun.gunzipSync(new Uint8Array(compressed))
22
+ );
23
+ const assets = JSON.parse(json) as ProductionSpaAssets;
24
+ const firstJsPath = assets.html.match(/src="(\/[^"]+\.js)"/u)?.[1];
25
+ const firstJs = firstJsPath ? assets.files[firstJsPath] : undefined;
26
+ if (!firstJs?.text.includes(ROOT_MOUNT_MARKER)) {
27
+ throw new Error(
28
+ "Embedded production SPA is missing the #root mount entry"
29
+ );
30
+ }
31
+ return assets;
32
+ };
33
+
34
+ export const getProductionSpaAssets =
35
+ async (): Promise<ProductionSpaAssets> => {
36
+ // Source and compiled serve both load the committed snapshot. Rebuilding
37
+ // with Bun.build on every `gno serve` blocked first listen on Windows
38
+ // Bun 1.3.11 long enough for the watcher smoke (10s) to miss readiness.
39
+ // Refresh the snapshot with `bun scripts/build-spa-production.ts`.
40
+ // `--dev` keeps the live HTMLBundle. Tests that need a live rebuild call
41
+ // `buildProductionSpaAssets` directly.
42
+ return loadEmbeddedProductionSpa();
43
+ };
@@ -0,0 +1,4 @@
1
+ declare module "*.json.gz" {
2
+ const path: string;
3
+ export default path;
4
+ }
@@ -1 +0,0 @@
1
- 8d8b73a55081409e3dbba4e6927d864839329cbcdb98a8f78274c29688edeb9e gno-browser-clipper-v1.36.0.zip