kcg-code 0.2.0 → 0.2.2

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.
Files changed (72) hide show
  1. package/dist/web/chunk-0k94bd7w.js +1 -0
  2. package/dist/web/chunk-1hsbtrt7.js +1 -0
  3. package/dist/web/chunk-1w20559j.js +2 -0
  4. package/dist/web/chunk-2g1d0p8k.js +1 -0
  5. package/dist/web/chunk-2y991ert.js +1 -0
  6. package/dist/web/chunk-3zr9b6sh.js +1 -0
  7. package/dist/web/chunk-4587k1kk.js +1 -0
  8. package/dist/web/chunk-4n8esv61.js +2 -0
  9. package/dist/web/chunk-5a2wt1wn.js +1 -0
  10. package/dist/web/chunk-6f4fhpq5.js +1 -0
  11. package/dist/web/chunk-70gs5473.js +2 -0
  12. package/dist/web/chunk-7fh46gy9.js +1 -0
  13. package/dist/web/chunk-7hpamxpk.js +1 -0
  14. package/dist/web/chunk-7k2ksd6m.js +1 -0
  15. package/dist/web/chunk-7r3qwgvk.js +1 -0
  16. package/dist/web/chunk-7vp7mn8t.js +1 -0
  17. package/dist/web/chunk-7zd31pph.js +2 -0
  18. package/dist/web/chunk-98m1ye59.js +1 -0
  19. package/dist/web/chunk-99ac747k.js +98 -0
  20. package/dist/web/chunk-9e08jfg6.js +1 -0
  21. package/dist/web/chunk-a4kekjaw.js +2 -0
  22. package/dist/web/chunk-a7dtx8vc.js +2 -0
  23. package/dist/web/chunk-aat7sg2w.js +2 -0
  24. package/dist/web/chunk-adfxb1sz.js +141 -0
  25. package/dist/web/chunk-c29x9yr7.js +1 -0
  26. package/dist/web/chunk-c84m5n2b.js +1 -0
  27. package/dist/web/chunk-deqmvh2y.js +1 -0
  28. package/dist/web/chunk-dwa0v44t.js +1 -0
  29. package/dist/web/chunk-ewkw0yxq.js +1 -0
  30. package/dist/web/chunk-fwhn9kja.js +1 -0
  31. package/dist/web/chunk-gzmwacrs.js +1 -0
  32. package/dist/web/chunk-jcnm8ap3.js +1 -0
  33. package/dist/web/chunk-jhww4q3p.css +1 -0
  34. package/dist/web/chunk-jt618p78.js +1 -0
  35. package/dist/web/chunk-k74h7e4d.js +2 -0
  36. package/dist/web/chunk-n2293q4w.js +2 -0
  37. package/dist/web/chunk-neqyf1jm.js +1 -0
  38. package/dist/web/chunk-nnhyekc9.js +1 -0
  39. package/dist/web/chunk-p5m4kcyp.js +1 -0
  40. package/dist/web/chunk-pae7wjqv.js +1 -0
  41. package/dist/web/chunk-pbh1r1h9.js +2 -0
  42. package/dist/web/chunk-qnky191x.js +1 -0
  43. package/dist/web/chunk-rachd7gd.js +1 -0
  44. package/dist/web/chunk-rcvsf7wj.js +2 -0
  45. package/dist/web/chunk-rj1164tm.js +1 -0
  46. package/dist/web/chunk-s0wykzpf.js +1 -0
  47. package/dist/web/chunk-sm5g3apm.js +1 -0
  48. package/dist/web/chunk-srr11f10.js +2 -0
  49. package/dist/web/chunk-tsyvyw54.js +2 -0
  50. package/dist/web/chunk-twh19bw5.js +2 -0
  51. package/dist/web/chunk-v0b1rz8b.js +1 -0
  52. package/dist/web/chunk-vcj3m7wp.js +1 -0
  53. package/dist/web/chunk-wqjjkgh9.js +1 -0
  54. package/dist/web/chunk-xgcdt3gc.js +2 -0
  55. package/dist/web/chunk-yenm06gj.js +1 -0
  56. package/dist/web/chunk-yvhjz3k7.js +12 -0
  57. package/dist/web/chunk-yymd449s.js +1 -0
  58. package/dist/web/chunk-z5ns215x.js +2 -0
  59. package/dist/web/index.html +14 -0
  60. package/dist/web/logo-sg2x36yb.svg +31 -0
  61. package/dist/web/logo.svg +1 -0
  62. package/dist/web/manifest.json +18 -0
  63. package/dist/web/sw.js +67 -0
  64. package/package.json +4 -3
  65. package/public/sw.js +20 -2
  66. package/src/cli/commands.ts +6 -1
  67. package/src/cli/dashboard.tsx +5 -1
  68. package/src/cli/ink-react.ts +26 -0
  69. package/src/paths.ts +3 -0
  70. package/src/server/__tests__/prebuilt-web.test.ts +35 -0
  71. package/src/server/app.ts +8 -0
  72. package/src/server/prebuilt-web.ts +46 -0
@@ -0,0 +1 @@
1
+ <svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "KCG Code",
3
+ "short_name": "KCG Code",
4
+ "description": "Kontrol CLI AI Agent (OpenCode / Claude Code) dari perangkat mobile.",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#0a0a0a",
8
+ "theme_color": "#0a0a0a",
9
+ "lang": "id",
10
+ "icons": [
11
+ {
12
+ "src": "/logo.svg",
13
+ "sizes": "any",
14
+ "type": "image/svg+xml",
15
+ "purpose": "any"
16
+ }
17
+ ]
18
+ }
package/dist/web/sw.js ADDED
@@ -0,0 +1,67 @@
1
+ // KCG Code service worker (Requirement 8.1).
2
+ // - Cache-first untuk asset statis (JS/CSS bundle hasil build.ts).
3
+ // - Network-only (pass-through) untuk `/api/*` dan koneksi WebSocket `/ws`,
4
+ // agar kontrol CLI_Agent tidak pernah disajikan dari cache basi.
5
+
6
+ // Naikkan versi bila strategi cache berubah — cache lama dihapus saat activate.
7
+ const CACHE_NAME = "kcg-code-v2";
8
+ const PRECACHE = ["/", "/manifest.json", "/logo.svg"];
9
+
10
+ self.addEventListener("install", (event) => {
11
+ event.waitUntil(
12
+ caches
13
+ .open(CACHE_NAME)
14
+ .then((cache) => cache.addAll(PRECACHE))
15
+ .catch(() => {}),
16
+ );
17
+ self.skipWaiting();
18
+ });
19
+
20
+ self.addEventListener("activate", (event) => {
21
+ event.waitUntil(
22
+ caches
23
+ .keys()
24
+ .then((keys) =>
25
+ Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
26
+ ),
27
+ );
28
+ self.clients.claim();
29
+ });
30
+
31
+ self.addEventListener("fetch", (event) => {
32
+ const url = new URL(event.request.url);
33
+
34
+ // Network-only untuk API dan WebSocket.
35
+ if (url.pathname.startsWith("/api/") || url.pathname === "/ws") return;
36
+
37
+ // Network-first untuk halaman: shell HTML lama (chunk usang) tidak boleh
38
+ // terjebak di cache setelah update. Cache hanya dipakai saat offline.
39
+ if (event.request.mode === "navigate") {
40
+ event.respondWith(
41
+ fetch(event.request)
42
+ .then((response) => {
43
+ if (response.ok) {
44
+ const clone = response.clone();
45
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
46
+ }
47
+ return response;
48
+ })
49
+ .catch(() => caches.match(event.request).then((cached) => cached ?? caches.match("/"))),
50
+ );
51
+ return;
52
+ }
53
+
54
+ // Cache-first untuk asset statis (chunk ber-hash).
55
+ event.respondWith(
56
+ caches.match(event.request).then((cached) => {
57
+ if (cached) return cached;
58
+ return fetch(event.request).then((response) => {
59
+ if (response.ok && url.origin === self.location.origin) {
60
+ const clone = response.clone();
61
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
62
+ }
63
+ return response;
64
+ });
65
+ }),
66
+ );
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcg-code",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Control CLI AI agents (OpenCode / Claude Code) from your phone — mobile PWA + local headless bridge.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "bin",
12
12
  "src",
13
13
  "public",
14
+ "dist/web",
14
15
  "styles",
15
16
  "bun-env.d.ts",
16
17
  "tsconfig.json",
@@ -40,8 +41,8 @@
40
41
  "lint:fix": "biome check --write .",
41
42
  "format": "biome format --write .",
42
43
  "typecheck": "tsc --noEmit",
43
- "prepack": "bun scripts/stamp-build-config.ts",
44
- "postpack": "bun scripts/stamp-build-config.ts --reset"
44
+ "prepack": "bun scripts/stamp-build-config.ts && bun run build.ts --outdir dist/web --no-sourcemap",
45
+ "postpack": "bun scripts/stamp-build-config.ts --reset && bun -e \"require('node:fs').rmSync('dist/web', { recursive: true, force: true })\""
45
46
  },
46
47
  "dependencies": {
47
48
  "@anthropic-ai/claude-agent-sdk": "^0.3.296",
package/public/sw.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // - Network-only (pass-through) untuk `/api/*` dan koneksi WebSocket `/ws`,
4
4
  // agar kontrol CLI_Agent tidak pernah disajikan dari cache basi.
5
5
 
6
- const CACHE_NAME = "kcg-code-v1";
6
+ // Naikkan versi bila strategi cache berubah — cache lama dihapus saat activate.
7
+ const CACHE_NAME = "kcg-code-v2";
7
8
  const PRECACHE = ["/", "/manifest.json", "/logo.svg"];
8
9
 
9
10
  self.addEventListener("install", (event) => {
@@ -33,7 +34,24 @@ self.addEventListener("fetch", (event) => {
33
34
  // Network-only untuk API dan WebSocket.
34
35
  if (url.pathname.startsWith("/api/") || url.pathname === "/ws") return;
35
36
 
36
- // Cache-first untuk asset statis.
37
+ // Network-first untuk halaman: shell HTML lama (chunk usang) tidak boleh
38
+ // terjebak di cache setelah update. Cache hanya dipakai saat offline.
39
+ if (event.request.mode === "navigate") {
40
+ event.respondWith(
41
+ fetch(event.request)
42
+ .then((response) => {
43
+ if (response.ok) {
44
+ const clone = response.clone();
45
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
46
+ }
47
+ return response;
48
+ })
49
+ .catch(() => caches.match(event.request).then((cached) => cached ?? caches.match("/"))),
50
+ );
51
+ return;
52
+ }
53
+
54
+ // Cache-first untuk asset statis (chunk ber-hash).
37
55
  event.respondWith(
38
56
  caches.match(event.request).then((cached) => {
39
57
  if (cached) return cached;
@@ -13,10 +13,12 @@ import {
13
13
  resolveEffectiveConfigPath,
14
14
  resolveEffectiveDbPath,
15
15
  resolveEffectiveUploadsDir,
16
+ WEB_DIST_DIR,
16
17
  } from "../paths";
17
18
  import { isCliMode } from "../runtime";
18
19
  import { createKcgServer } from "../server/app";
19
20
  import { resolveHostname } from "../server/host";
21
+ import { loadPrebuiltWeb } from "../server/prebuilt-web";
20
22
  import { createAuthService } from "../server/services/auth";
21
23
  import type { CliArgs } from "./args";
22
24
  import { attachToDaemon } from "./attach";
@@ -110,10 +112,13 @@ export async function runStart(args: CliArgs): Promise<void> {
110
112
  return;
111
113
  }
112
114
 
115
+ // Paket npm membawa frontend prebuilt; checkout git jatuh ke bundling runtime.
116
+ const web = loadPrebuiltWeb(WEB_DIST_DIR);
113
117
  const boot = (port: number) =>
114
118
  createKcgServer({
115
119
  config,
116
- spa,
120
+ spa: web?.spa ?? spa,
121
+ assets: web?.assets,
117
122
  spaPaths: SPA_PATHS,
118
123
  port,
119
124
  hostname,
@@ -5,12 +5,16 @@
5
5
  */
6
6
  import { networkInterfaces } from "node:os";
7
7
  import { Box, render, renderToString, Text, useInput } from "ink";
8
- import { type ReactNode, useEffect, useReducer, useRef, useState } from "react";
8
+ import type { ReactNode } from "react";
9
9
  import { renderUnicodeCompact } from "uqr";
10
10
  import type { KcgServer } from "../server/app";
11
+ import { InkReact } from "./ink-react";
11
12
  import type { KeepAwake } from "./keep-awake";
12
13
  import { logoLines, readPackageVersion } from "./theme";
13
14
 
15
+ // Hook wajib dari instance React milik Ink — lihat ink-react.ts.
16
+ const { useEffect, useReducer, useRef, useState } = InkReact;
17
+
14
18
  export type TunnelProviderId = "kcg" | "lhr";
15
19
 
16
20
  interface KcgTunnelStatus {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * React yang sama dengan yang dipakai renderer Ink.
3
+ *
4
+ * Pada install global (`bun i -g kcg-code`), `ink` bisa di-hoist ke
5
+ * node_modules global bersama React milik paket lain, sementara kcg-code
6
+ * mendapat salinan React sendiri (`kcg-code/node_modules/react`). Hook dari
7
+ * salinan itu tidak dikenali reconciler Ink → "Invalid hook call".
8
+ *
9
+ * Solusinya: ambil React dari lokasi `react-reconciler` milik Ink, sehingga
10
+ * hook dan reconciler selalu memakai instance yang sama.
11
+ */
12
+ import { createRequire } from "node:module";
13
+ import * as LocalReact from "react";
14
+
15
+ function resolveInkReact(): typeof LocalReact {
16
+ try {
17
+ const inkRequire = createRequire(Bun.resolveSync("ink", import.meta.dir));
18
+ const reconcilerRequire = createRequire(inkRequire.resolve("react-reconciler"));
19
+ return reconcilerRequire("react") as typeof LocalReact;
20
+ } catch {
21
+ // Layout tak terduga — pakai React milik paket ini.
22
+ return LocalReact;
23
+ }
24
+ }
25
+
26
+ export const InkReact = resolveInkReact();
package/src/paths.ts CHANGED
@@ -20,6 +20,9 @@ export const PACKAGE_ROOT = path.resolve(import.meta.dir, "..");
20
20
  /** Direktori asset statis PWA di dalam paket. */
21
21
  export const PUBLIC_DIR = path.join(PACKAGE_ROOT, "public");
22
22
 
23
+ /** Frontend hasil build yang ikut paket npm (`build.ts --outdir dist/web`, saat prepack). */
24
+ export const WEB_DIST_DIR = path.join(PACKAGE_ROOT, "dist", "web");
25
+
23
26
  /** Direktori aplikasi milik user: `~/.kcgcode`. */
24
27
  export const KCG_HOME = path.join(homedir(), ".kcgcode");
25
28
 
@@ -0,0 +1,35 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { loadPrebuiltWeb } from "../prebuilt-web";
6
+
7
+ describe("loadPrebuiltWeb", () => {
8
+ let dir = "";
9
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
10
+
11
+ test("null bila belum di-build", () => {
12
+ dir = mkdtempSync(path.join(tmpdir(), "kcg-web-"));
13
+ expect(loadPrebuiltWeb(dir)).toBeNull();
14
+ });
15
+
16
+ test("shell SPA + rute chunk; sourcemap tidak diekspos", async () => {
17
+ dir = mkdtempSync(path.join(tmpdir(), "kcg-web-"));
18
+ writeFileSync(path.join(dir, "index.html"), "<!doctype html><title>x</title>");
19
+ writeFileSync(path.join(dir, "chunk-abc123.js"), "console.log(1)");
20
+ writeFileSync(path.join(dir, "chunk-abc123.js.map"), "{}");
21
+ mkdirSync(path.join(dir, "icons"));
22
+ writeFileSync(path.join(dir, "icons", "a.png"), "png");
23
+
24
+ const web = loadPrebuiltWeb(dir);
25
+ expect(web).not.toBeNull();
26
+ if (!web) return;
27
+ expect(Object.keys(web.assets).sort()).toEqual(["/chunk-abc123.js", "/icons/a.png"]);
28
+ expect(web.spa.headers.get("content-type")).toContain("text/html");
29
+
30
+ const chunk = web.assets["/chunk-abc123.js"]?.() as Response;
31
+ expect(await chunk.text()).toBe("console.log(1)");
32
+ expect(chunk.headers.get("cache-control")).toContain("immutable");
33
+ expect(web.assets["/icons/a.png"]?.().headers.get("cache-control")).toBe("no-cache");
34
+ });
35
+ });
package/src/server/app.ts CHANGED
@@ -114,6 +114,11 @@ export interface KcgServerOptions {
114
114
  * (`src/index.ts`) persis seperti daftar rute SPA kcgcode; path lain 404.
115
115
  */
116
116
  spaPaths?: string[];
117
+ /**
118
+ * Asset frontend hasil build (`dist/web`) per path URL — dipakai binary
119
+ * global sebagai pengganti bundling HTML saat runtime.
120
+ */
121
+ assets?: Record<string, () => Response>;
117
122
  }
118
123
 
119
124
  export interface KcgServer {
@@ -321,6 +326,9 @@ export function createKcgServer(opts: KcgServerOptions = {}): KcgServer {
321
326
  hostname,
322
327
  port,
323
328
  routes: {
329
+ // ---- Chunk frontend prebuilt (bila ada); rute eksplisit di bawah menang ----
330
+ ...opts.assets,
331
+
324
332
  // ---- Asset statis PWA dari root paket (bukan cwd) — aman utk global install ----
325
333
  "/manifest.json": () =>
326
334
  staticFile(path.join(PUBLIC_DIR, "manifest.json"), "application/manifest+json"),
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Frontend hasil build (`build.ts --outdir dist/web`) yang ikut paket npm.
3
+ *
4
+ * Binary global memakai ini, bukan bundling HTML import saat runtime: di
5
+ * node_modules global bisa ada dua salinan React (library ter-hoist memakai
6
+ * React milik paket lain → "Cannot read properties of null (reading
7
+ * 'useState')"), dan plugin Tailwind dari bunfig.toml hanya terbaca bila cwd
8
+ * = root repo. Build dilakukan sekali di repo (prepack) dengan satu React.
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export type AssetRoutes = Record<string, () => Response>;
14
+
15
+ export interface PrebuiltWeb {
16
+ /** Shell SPA (index.html) untuk semua `spaPaths`. */
17
+ spa: Response;
18
+ /** Chunk JS/CSS & asset lain, per path URL. */
19
+ assets: AssetRoutes;
20
+ }
21
+
22
+ /** Chunk ber-hash aman di-cache selamanya; selain itu selalu revalidasi. */
23
+ function cacheControl(file: string): string {
24
+ return /^chunk-[a-z0-9]+\./.test(path.basename(file)) || /-[a-z0-9]{8}\.\w+$/.test(file)
25
+ ? "public, max-age=31536000, immutable"
26
+ : "no-cache";
27
+ }
28
+
29
+ /** Rute frontend dari `dir`; null bila belum di-build (mis. checkout git). */
30
+ export function loadPrebuiltWeb(dir: string): PrebuiltWeb | null {
31
+ const indexPath = path.join(dir, "index.html");
32
+ if (!existsSync(indexPath)) return null;
33
+
34
+ const assets: AssetRoutes = {};
35
+ for (const rel of new Bun.Glob("**/*").scanSync({ cwd: dir })) {
36
+ if (rel === "index.html" || rel.endsWith(".map")) continue;
37
+ const file = path.join(dir, rel);
38
+ const headers = { "cache-control": cacheControl(rel) };
39
+ assets[`/${rel.split(path.sep).join("/")}`] = () => new Response(Bun.file(file), { headers });
40
+ }
41
+
42
+ const spa = new Response(readFileSync(indexPath), {
43
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" },
44
+ });
45
+ return { spa, assets };
46
+ }