@gmickel/gno 1.36.1 → 1.37.1

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.
@@ -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,176 @@
1
+ // node:fs/promises — no Bun equivalent for mkdir/rm of the temporary SPA
2
+ // outdir, or for recursive directory listing.
3
+ import { mkdir, readdir, rm } from "node:fs/promises";
4
+ // node:os — no Bun equivalent for the platform temporary directory.
5
+ import { tmpdir } from "node:os";
6
+ // node:path — no Bun path utils.
7
+ import { basename, join, relative } from "node:path";
8
+
9
+ export const ROOT_MOUNT_MARKER = 'getElementById("root")';
10
+
11
+ export type ProductionSpaFile = {
12
+ text: string;
13
+ type: string;
14
+ };
15
+
16
+ export type ProductionSpaAssets = {
17
+ files: Record<string, ProductionSpaFile>;
18
+ html: string;
19
+ sourceHash: string;
20
+ };
21
+
22
+ const productionSpaPublicDir = (): string => join(import.meta.dir, "public");
23
+
24
+ export const productionSpaEntryPath = (): string =>
25
+ join(productionSpaPublicDir(), "index.html");
26
+
27
+ /**
28
+ * SHA-256 hex of every file under `src/serve/public/`, in sorted relative-path
29
+ * order. Used to detect a stale `assets/spa-production.json.gz` without
30
+ * comparing Bun.build output (minified symbols and chunk hashes differ
31
+ * across Bun binaries).
32
+ */
33
+ export const computeSpaSourceHash = async (): Promise<string> => {
34
+ const publicDir = productionSpaPublicDir();
35
+ const entries = await readdir(publicDir, {
36
+ recursive: true,
37
+ withFileTypes: true,
38
+ });
39
+ const relativePaths: string[] = [];
40
+ for (const entry of entries) {
41
+ if (!entry.isFile()) {
42
+ continue;
43
+ }
44
+ relativePaths.push(
45
+ relative(publicDir, join(entry.parentPath, entry.name)).replaceAll(
46
+ "\\",
47
+ "/"
48
+ )
49
+ );
50
+ }
51
+ relativePaths.sort();
52
+
53
+ const hasher = new Bun.CryptoHasher("sha256");
54
+ for (const relativePath of relativePaths) {
55
+ const bytes = await Bun.file(join(publicDir, relativePath)).bytes();
56
+ hasher.update(relativePath);
57
+ hasher.update("\0");
58
+ hasher.update(String(bytes.byteLength));
59
+ hasher.update("\0");
60
+ hasher.update(bytes);
61
+ }
62
+ return hasher.digest("hex");
63
+ };
64
+
65
+ const SCRIPT_TAG_RE = /<script\b[^>]*\bsrc="[^"]+"[^>]*><\/script>/iu;
66
+ const BASE_TAG_RE = /<base\b[^>]*>/iu;
67
+ const CROSSORIGIN_ATTR_RE = /\s+crossorigin(?:=(?:"[^"]*"|'[^']*'))?/gu;
68
+
69
+ const contentTypeFor = (path: string): string => {
70
+ if (path.endsWith(".css")) {
71
+ return "text/css;charset=utf-8";
72
+ }
73
+ if (path.endsWith(".js")) {
74
+ return "text/javascript;charset=utf-8";
75
+ }
76
+ if (path.endsWith(".map")) {
77
+ return "application/json";
78
+ }
79
+ return "application/octet-stream";
80
+ };
81
+
82
+ export const isStandaloneExecutable = (): boolean =>
83
+ import.meta.path.includes("/$bunfs/") ||
84
+ import.meta.path.includes("\\$bunfs\\");
85
+
86
+ export const isBunfsPath = (path: string): boolean =>
87
+ path.includes("/$bunfs/") || path.includes("\\$bunfs\\");
88
+
89
+ const rewriteProductionHtml = (html: string, jsEntryPath: string): string => {
90
+ const script = `<script type="module" src="/${basename(jsEntryPath)}"></script>`;
91
+ let next = html.replace(BASE_TAG_RE, "");
92
+ if (SCRIPT_TAG_RE.test(next)) {
93
+ next = next.replace(SCRIPT_TAG_RE, script);
94
+ } else {
95
+ throw new Error("Production SPA HTML is missing a module script tag");
96
+ }
97
+ return next.replace(CROSSORIGIN_ATTR_RE, "");
98
+ };
99
+
100
+ export const buildProductionSpaAssets = async (
101
+ entryPath: string = productionSpaEntryPath()
102
+ ): Promise<ProductionSpaAssets> => {
103
+ if (isBunfsPath(entryPath)) {
104
+ throw new Error(
105
+ `Cannot Bun.build a production SPA from bunfs (${entryPath})`
106
+ );
107
+ }
108
+
109
+ const outdir = join(
110
+ tmpdir(),
111
+ `gno-spa-build-${crypto.randomUUID().replaceAll("-", "").slice(0, 20)}`
112
+ );
113
+ await mkdir(outdir, { recursive: true });
114
+
115
+ let result: Awaited<ReturnType<typeof Bun.build>>;
116
+ try {
117
+ result = await Bun.build({
118
+ entrypoints: [entryPath],
119
+ minify: true,
120
+ outdir,
121
+ publicPath: "/",
122
+ splitting: true,
123
+ target: "browser",
124
+ });
125
+ } catch (error) {
126
+ await rm(outdir, { recursive: true, force: true });
127
+ throw error;
128
+ }
129
+
130
+ if (!result.success) {
131
+ await rm(outdir, { recursive: true, force: true });
132
+ throw new Error("Production SPA split build failed");
133
+ }
134
+
135
+ try {
136
+ const htmlArtifact = result.outputs.find((output) =>
137
+ output.path.endsWith(".html")
138
+ );
139
+ const jsEntry = result.outputs.find(
140
+ (output) => output.kind === "entry-point" && output.path.endsWith(".js")
141
+ );
142
+ if (!htmlArtifact || !jsEntry) {
143
+ throw new Error(
144
+ "Production SPA split build did not emit HTML and JS entry"
145
+ );
146
+ }
147
+
148
+ const jsEntryText = await jsEntry.text();
149
+ if (!jsEntryText.includes(ROOT_MOUNT_MARKER)) {
150
+ throw new Error(
151
+ "Production SPA JS entry does not mount #root; HTML would leave the shell blank"
152
+ );
153
+ }
154
+
155
+ const html = rewriteProductionHtml(await htmlArtifact.text(), jsEntry.path);
156
+ const files: Record<string, ProductionSpaFile> = {};
157
+ for (const output of result.outputs) {
158
+ if (output.path.endsWith(".html")) {
159
+ continue;
160
+ }
161
+ const publicPath = `/${basename(output.path)}`;
162
+ files[publicPath] = {
163
+ text: await output.text(),
164
+ type: contentTypeFor(output.path),
165
+ };
166
+ }
167
+
168
+ return {
169
+ files,
170
+ html,
171
+ sourceHash: await computeSpaSourceHash(),
172
+ };
173
+ } finally {
174
+ await rm(outdir, { recursive: true, force: true });
175
+ }
176
+ };
@@ -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
- 8ece31111978af50978a899662a7bd14eddcc90f2c90f6b0f04b67b3b602d980 gno-browser-clipper-v1.36.1.zip