@circuitwall/jarela 0.7.1 → 0.7.3

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 (94) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +51 -35
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js.map +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +2 -2
  25. package/.next/standalone/.next/server/app/index.html +2 -2
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page.js +515 -104
  33. package/.next/standalone/.next/server/app/page.js.map +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/setup.html +1 -1
  37. package/.next/standalone/.next/server/app/setup.rsc +2 -2
  38. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/setup.segments/_head.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/setup.segments/_index.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  42. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
  43. package/.next/standalone/.next/server/app/setup.segments/setup.segment.rsc +1 -1
  44. package/.next/standalone/.next/server/chunks/1683.js +26 -16
  45. package/.next/standalone/.next/server/chunks/1683.js.map +1 -1
  46. package/.next/standalone/.next/server/chunks/{317.js → 5432.js} +11100 -10858
  47. package/.next/standalone/.next/server/chunks/5432.js.map +1 -0
  48. package/.next/standalone/.next/server/chunks/7885.js +606 -353
  49. package/.next/standalone/.next/server/chunks/7885.js.map +1 -1
  50. package/.next/standalone/.next/server/chunks/8135.js +59 -16
  51. package/.next/standalone/.next/server/chunks/8135.js.map +1 -1
  52. package/.next/standalone/.next/server/chunks/9032.js +3 -3
  53. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  54. package/.next/standalone/.next/server/instrumentation.js +3 -3
  55. package/.next/standalone/.next/server/instrumentation.js.map +1 -1
  56. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  57. package/.next/standalone/.next/server/pages/404.html +2 -2
  58. package/.next/standalone/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/static/chunks/app/{page-a20902703c0a4f10.js → page-9fb006074fb13526.js} +582 -171
  61. package/.next/standalone/.next/static/chunks/app/page-9fb006074fb13526.js.map +1 -0
  62. package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css +5 -0
  63. package/.next/standalone/.next/static/css/5507dbe1cdc6c599.css.map +1 -0
  64. package/.next/standalone/package.json +2 -1
  65. package/CHANGELOG.md +22 -0
  66. package/README.md +83 -1
  67. package/api/types.ts +7 -0
  68. package/app/api/v1/agents/[id]/compact/route.ts +2 -40
  69. package/components/bridges/BridgeEditor.tsx +8 -0
  70. package/components/chat/MessageBubble.tsx +3 -36
  71. package/components/models/ModelEditor.tsx +141 -0
  72. package/components/scheduled-tasks/ScheduledTasksPanel.tsx +5 -0
  73. package/components/scheduled-tasks/WatchersSection.tsx +5 -0
  74. package/lib/agents/context-budget.test.ts +128 -0
  75. package/lib/agents/context-budget.ts +128 -0
  76. package/lib/agents/conversation-summary.test.ts +68 -0
  77. package/lib/agents/conversation-summary.ts +51 -0
  78. package/lib/agents/run-thread.ts +112 -2
  79. package/lib/bridges/dispatcher.test.ts +134 -0
  80. package/lib/bridges/dispatcher.ts +34 -16
  81. package/lib/bridges/message-role.test.ts +83 -0
  82. package/lib/bridges/message-role.ts +46 -0
  83. package/lib/triggers/handlers/watcher.test.ts +23 -4
  84. package/lib/triggers/handlers/watcher.ts +56 -8
  85. package/package.json +2 -1
  86. package/scripts/jarela-bin.mjs +9 -0
  87. package/scripts/optimize-client-chunks.mjs +144 -0
  88. package/scripts/start-prod.mjs +10 -0
  89. package/.next/standalone/.next/server/chunks/317.js.map +0 -1
  90. package/.next/standalone/.next/static/chunks/app/page-a20902703c0a4f10.js.map +0 -1
  91. package/.next/standalone/.next/static/css/cc66c456aba91258.css +0 -5
  92. package/.next/standalone/.next/static/css/cc66c456aba91258.css.map +0 -1
  93. /package/.next/standalone/.next/static/{DrvaYJKeZM57UgVo0To-x → AbCOWpaxP4v4lUSeFWWYz}/_buildManifest.js +0 -0
  94. /package/.next/standalone/.next/static/{DrvaYJKeZM57UgVo0To-x → AbCOWpaxP4v4lUSeFWWYz}/_ssgManifest.js +0 -0
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatBridgePrompt, parseBridgePrompt } from "./message-role";
3
+
4
+ describe("bridge prompt envelope", () => {
5
+ it("round-trips DM prompt metadata and body", () => {
6
+ const raw = formatBridgePrompt({
7
+ bridge_id: "b1",
8
+ chat_id: "dm@jid",
9
+ chat_name: "Alice",
10
+ is_group: false,
11
+ role: "counterpart",
12
+ sender_id: "alice@jid",
13
+ sender_name: "Alice",
14
+ text: "hello from dm",
15
+ });
16
+ const parsed = parseBridgePrompt(raw);
17
+ expect(parsed).not.toBeNull();
18
+ expect(parsed?.bridgeId).toBe("b1");
19
+ expect(parsed?.chatJid).toBe("dm@jid");
20
+ expect(parsed?.isGroup).toBe(false);
21
+ expect(parsed?.senderJid).toBe("alice@jid");
22
+ expect(parsed?.body).toBe("hello from dm");
23
+ });
24
+
25
+ it("round-trips group prompt metadata and body", () => {
26
+ const raw = formatBridgePrompt({
27
+ bridge_id: "b1",
28
+ chat_id: "group@jid",
29
+ chat_name: "Family Group",
30
+ is_group: true,
31
+ role: "counterpart",
32
+ sender_id: "bob@jid",
33
+ sender_name: "Bob",
34
+ text: "group message",
35
+ });
36
+ const parsed = parseBridgePrompt(raw);
37
+ expect(parsed).not.toBeNull();
38
+ expect(parsed?.chatJid).toBe("group@jid");
39
+ expect(parsed?.chatName).toBe("Family Group");
40
+ expect(parsed?.isGroup).toBe(true);
41
+ expect(parsed?.senderName).toBe("Bob");
42
+ expect(parsed?.body).toBe("group message");
43
+ });
44
+
45
+ it("parses envelopes with prose preface before bracket headers", () => {
46
+ const raw = [
47
+ "The paired user themselves sent the message below.",
48
+ "",
49
+ "[bridge:b1]",
50
+ "[chat_id:dm@jid]",
51
+ "[chat_name:Alice]",
52
+ "[chat_type:dm]",
53
+ "[sender_id:alice@jid]",
54
+ "[sender_name:Alice]",
55
+ "",
56
+ "body",
57
+ ].join("\n");
58
+ const parsed = parseBridgePrompt(raw);
59
+ expect(parsed?.bridgeId).toBe("b1");
60
+ expect(parsed?.body).toBe("body");
61
+ });
62
+
63
+ it("accepts legacy key names for compatibility", () => {
64
+ const raw = [
65
+ "[bridge:b1]",
66
+ "[chat_jid:legacy@jid]",
67
+ "[chat_name:Legacy]",
68
+ "[chat_type:dm]",
69
+ "[sender_jid:sender@jid]",
70
+ "[sender_name:Sender]",
71
+ "",
72
+ "legacy body",
73
+ ].join("\n");
74
+ const parsed = parseBridgePrompt(raw);
75
+ expect(parsed?.chatJid).toBe("legacy@jid");
76
+ expect(parsed?.senderJid).toBe("sender@jid");
77
+ expect(parsed?.body).toBe("legacy body");
78
+ });
79
+
80
+ it("returns null when not a bridge envelope", () => {
81
+ expect(parseBridgePrompt("plain text")).toBeNull();
82
+ });
83
+ });
@@ -56,6 +56,18 @@ export interface BridgePromptInput {
56
56
  text: string;
57
57
  }
58
58
 
59
+ // Parsed chat-friendly envelope extracted from a bridge prompt body.
60
+ // Used by the chat UI so rendering stays in lockstep with formatter changes.
61
+ export interface BridgePromptContext {
62
+ bridgeId: string;
63
+ chatJid: string;
64
+ chatName: string;
65
+ isGroup: boolean;
66
+ senderJid: string;
67
+ senderName: string;
68
+ body: string;
69
+ }
70
+
59
71
  /**
60
72
  * Build the prompt prefix the agent receives for one bridge-inbound message.
61
73
  *
@@ -97,6 +109,40 @@ export function formatBridgePrompt(input: BridgePromptInput): string {
97
109
  return `${note}\n\n${lines.join("\n")}\n\n${input.text}`;
98
110
  }
99
111
 
112
+ // Parses bridge prompt envelopes rendered by formatBridgePrompt().
113
+ // Back-compat: also accepts legacy keys (chat_jid/sender_jid) and optional
114
+ // prose preface before the [bridge:...] metadata block.
115
+ export function parseBridgePrompt(raw: string): BridgePromptContext | null {
116
+ const start = raw.indexOf("[bridge:");
117
+ if (start < 0) return null;
118
+ const src = raw.slice(start);
119
+
120
+ const headers: Record<string, string> = {};
121
+ const lines = src.split("\n");
122
+ let i = 0;
123
+ for (; i < lines.length; i++) {
124
+ const line = lines[i];
125
+ if (line === "") { i++; break; }
126
+ const m = /^\[([a-z_]+):([\s\S]*)\]$/.exec(line);
127
+ if (!m) return null;
128
+ headers[m[1]] = m[2];
129
+ }
130
+
131
+ const chatId = headers.chat_id || headers.chat_jid;
132
+ const senderId = headers.sender_id || headers.sender_jid || chatId;
133
+ if (!headers.bridge || !chatId || !headers.chat_type) return null;
134
+
135
+ return {
136
+ bridgeId: headers.bridge,
137
+ chatJid: chatId,
138
+ chatName: headers.chat_name || chatId,
139
+ isGroup: headers.chat_type === "group",
140
+ senderJid: senderId,
141
+ senderName: headers.sender_name || senderId || "Unknown",
142
+ body: lines.slice(i).join("\n").trimEnd(),
143
+ };
144
+ }
145
+
100
146
  function roleNote(role: MessageRole, isGroup: boolean): string {
101
147
  switch (role) {
102
148
  case "user":
@@ -75,12 +75,31 @@ describe("watcherHandler (ADR-0027)", () => {
75
75
  expect(fired.agentId).toBe("a");
76
76
  expect(fired.kind).toBe("watcher");
77
77
  expect(fired.prompt).toContain('Watcher "change" detected a change');
78
- expect(fired.prompt).toContain("v1");
79
- expect(fired.prompt).toContain("v2");
78
+ expect(fired.prompt).toContain("--- Diff (previous -> current) ---");
79
+ expect(fired.prompt).toContain("- v1");
80
+ expect(fired.prompt).toContain("+ v2");
81
+ expect(fired.prompt).not.toContain("--- Previous result ---");
82
+ expect(fired.prompt).not.toContain("--- Current result ---");
80
83
  const after = getWatcher(w.id)!;
81
84
  expect(after.last_fired_at).not.toBeNull();
82
85
  });
83
86
 
87
+ it("truncates oversized result payloads in the firing prompt", async () => {
88
+ const w = createWatcher({
89
+ agent_id: "a", label: "big", tool_name: "watcher_test_tool", interval_seconds: 60,
90
+ });
91
+ fakeResult = "A".repeat(5000);
92
+ await watcherHandler.getDueFirings(new Date(Date.parse(w.next_run_at) + 1));
93
+ fakeResult = "B".repeat(5000);
94
+ const w2 = getWatcher(w.id)!;
95
+ const firings = await watcherHandler.getDueFirings(new Date(Date.parse(w2.next_run_at) + 1));
96
+ expect(firings).toHaveLength(1);
97
+ const fired = firings[0];
98
+ if (fired.mode !== "prompt") throw new Error("expected prompt firing");
99
+ expect(fired.prompt).toContain("[diff truncated: showing");
100
+ expect(fired.prompt.length).toBeLessThan(8000);
101
+ });
102
+
84
103
  it("does NOT fire when result is unchanged across polls", async () => {
85
104
  const w = createWatcher({
86
105
  agent_id: "a", label: "stable", tool_name: "watcher_test_tool", interval_seconds: 60,
@@ -123,8 +142,8 @@ describe("watcherHandler (ADR-0027)", () => {
123
142
  expect(fired.prompt).toContain("Open a Jira ticket against the broken dashboard.");
124
143
  expect(fired.prompt).not.toContain("Summarise what changed");
125
144
  // Diff envelope is preserved.
126
- expect(fired.prompt).toContain("v1");
127
- expect(fired.prompt).toContain("v2");
145
+ expect(fired.prompt).toContain("- v1");
146
+ expect(fired.prompt).toContain("+ v2");
128
147
  });
129
148
 
130
149
  it("uses the default directive when reaction_prompt is null", async () => {
@@ -6,7 +6,7 @@
6
6
  // tools must be context-free).
7
7
  // 3. Hash the stringified result and compare to last_fingerprint.
8
8
  // 4. If the hash differs from the previous run, return a TriggerFiring
9
- // whose prompt embeds {previous, current} as context for the agent.
9
+ // whose prompt embeds a compact previous->current diff for the agent.
10
10
  // If it matches (or this is the first run with no previous), record
11
11
  // the fingerprint and skip — no LLM call, no firing.
12
12
  // 5. Either way the watcher's next_run_at is advanced by
@@ -25,6 +25,7 @@ import {
25
25
  } from "@/lib/stores/watchers";
26
26
  import { registeredTools } from "@/lib/tools/registry";
27
27
  import { publish as publishNotification } from "@/lib/notifications/bus";
28
+ import { truncateBytes } from "@/lib/utils/text";
28
29
  import type { TriggerFiring, TriggerHandler, TriggerOutcome } from "../types";
29
30
 
30
31
  export const WATCHER_KIND = "watcher";
@@ -46,14 +47,64 @@ const DEFAULT_REACTION_DIRECTIVE =
46
47
  `Summarise what changed and decide whether the user needs to know. ` +
47
48
  `If nothing material changed, you may stay silent.`;
48
49
 
50
+ // Watcher tool outputs can be very large (full JSON payloads, long lists).
51
+ // Keep the diff context bounded so one firing cannot consume most of an
52
+ // agent's prompt budget.
53
+ const MAX_DIFF_CONTEXT_BYTES = 3500;
54
+
55
+ function normalizeForDiff(raw: string): string {
56
+ try {
57
+ return JSON.stringify(JSON.parse(raw), null, 2);
58
+ } catch {
59
+ return raw;
60
+ }
61
+ }
62
+
63
+ function buildDiffForPrompt(previous: string | null, current: string): string {
64
+ if (previous === null) return "+ (first observation baseline established; no diff available)";
65
+
66
+ const prev = normalizeForDiff(previous).split(/\r?\n/);
67
+ const curr = normalizeForDiff(current).split(/\r?\n/);
68
+
69
+ let start = 0;
70
+ while (start < prev.length && start < curr.length && prev[start] === curr[start]) {
71
+ start += 1;
72
+ }
73
+
74
+ let prevEnd = prev.length - 1;
75
+ let currEnd = curr.length - 1;
76
+ while (prevEnd >= start && currEnd >= start && prev[prevEnd] === curr[currEnd]) {
77
+ prevEnd -= 1;
78
+ currEnd -= 1;
79
+ }
80
+
81
+ const removed = prev.slice(start, prevEnd + 1);
82
+ const added = curr.slice(start, currEnd + 1);
83
+ if (removed.length === 0 && added.length === 0) {
84
+ return "(no textual diff after normalization)";
85
+ }
86
+
87
+ const hunkHeader = `@@ old:${start + 1}-${Math.max(start, prevEnd + 1)} new:${start + 1}-${Math.max(start, currEnd + 1)} @@`;
88
+ const raw = [
89
+ hunkHeader,
90
+ ...removed.map((l) => `- ${l}`),
91
+ ...added.map((l) => `+ ${l}`),
92
+ ].join("\n");
93
+
94
+ const bytes = Buffer.byteLength(raw, "utf8");
95
+ const clipped = truncateBytes(raw, MAX_DIFF_CONTEXT_BYTES);
96
+ if (!clipped.truncated) return raw;
97
+ return `${clipped.text}\n… [diff truncated: showing ${MAX_DIFF_CONTEXT_BYTES} of ${bytes} bytes; full values retained in watcher state]`;
98
+ }
99
+
49
100
  function buildFiringPrompt(watcher: WatcherRow, previous: string | null, current: string): string {
50
101
  const argsPretty = (() => {
51
102
  try { return JSON.stringify(JSON.parse(watcher.tool_args), null, 2); }
52
103
  catch { return watcher.tool_args; }
53
104
  })();
54
105
  // ADR-0030: a non-null reaction_prompt swaps in for the default directive.
55
- // The diff envelope (label/tool/args/previous/current) is unchanged so the
56
- // agent always has the change context regardless of the user's instruction.
106
+ // The diff envelope (label/tool/args/diff) is unchanged so the agent
107
+ // always has the change context regardless of the user's instruction.
57
108
  const directive = watcher.reaction_prompt?.trim() || DEFAULT_REACTION_DIRECTIVE;
58
109
  return [
59
110
  `Watcher "${watcher.label}" detected a change.`,
@@ -61,11 +112,8 @@ function buildFiringPrompt(watcher: WatcherRow, previous: string | null, current
61
112
  `Tool: ${watcher.tool_name}`,
62
113
  `Args: ${argsPretty}`,
63
114
  ``,
64
- `--- Previous result ---`,
65
- previous ?? "(none — first observation)",
66
- ``,
67
- `--- Current result ---`,
68
- current,
115
+ `--- Diff (previous -> current) ---`,
116
+ buildDiffForPrompt(previous, current),
69
117
  ``,
70
118
  directive,
71
119
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",
@@ -48,6 +48,7 @@
48
48
  "!public/sw.js",
49
49
  "!public/swe-worker-*.js",
50
50
  "scripts/jarela-bin.mjs",
51
+ "scripts/optimize-client-chunks.mjs",
51
52
  "scripts/service-install.mjs",
52
53
  "scripts/start-prod.mjs",
53
54
  "scripts/postbuild.mjs",
@@ -40,6 +40,12 @@ Environment:
40
40
  JARELA_VOICE_TIMEOUT_MS — Gemini voice request timeout (default 60000)
41
41
  JARELA_IMAGE_TIMEOUT_MS — Gemini image request timeout (default 60000)
42
42
  JARELA_DISABLE_UPDATE_CHECK — set to 1 to skip the npm update check
43
+ JARELA_PREFLIGHT_OPTIMIZE_CLIENT
44
+ — set to 1 to run one-time local chunk
45
+ optimization before server boot (default: 1
46
+ for npm/global install, 0 for source checkout)
47
+ JARELA_FORCE_PREFLIGHT_OPTIMIZE
48
+ — set to 1 to force re-running optimization
43
49
  `);
44
50
  }
45
51
 
@@ -110,5 +116,8 @@ if (process.env.JARELA_PORT) process.env.PORT = process.env.JARELA_PORT;
110
116
  if (process.env.JARELA_HOSTNAME) process.env.HOSTNAME = process.env.JARELA_HOSTNAME;
111
117
  process.env.PORT ||= "4312";
112
118
  process.env.HOSTNAME ||= "127.0.0.1";
119
+ if (installedUnderNodeModules) {
120
+ process.env.JARELA_PREFLIGHT_OPTIMIZE_CLIENT ||= "1";
121
+ }
113
122
  process.chdir(root);
114
123
  await import(new URL("./start-prod.mjs", import.meta.url).href);
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, relative, sep } from "node:path";
6
+ import { createRequire } from "node:module";
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ const DEFAULT_MARKER = join(
11
+ process.cwd(),
12
+ ".next",
13
+ "standalone",
14
+ ".next",
15
+ "static",
16
+ ".jarela-client-optimized.json",
17
+ );
18
+
19
+ function normalize(p) {
20
+ return p.split(sep).join("/");
21
+ }
22
+
23
+ async function listJsFiles(root) {
24
+ const out = [];
25
+ async function walk(dir) {
26
+ const entries = await readdir(dir, { withFileTypes: true });
27
+ for (const entry of entries) {
28
+ const full = join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ await walk(full);
31
+ continue;
32
+ }
33
+ if (entry.isFile() && entry.name.endsWith(".js")) {
34
+ out.push(full);
35
+ }
36
+ }
37
+ }
38
+ await walk(root);
39
+ return out;
40
+ }
41
+
42
+ function looksAlreadyMinified(code) {
43
+ const lines = code.split(/\r?\n/);
44
+ let maxLine = 0;
45
+ for (const line of lines) {
46
+ if (line.length > maxLine) maxLine = line.length;
47
+ }
48
+ return lines.length <= 25 && maxLine >= 600;
49
+ }
50
+
51
+ export async function optimizeClientChunksOnce(opts = {}) {
52
+ const standaloneRoot = opts.standaloneRoot ?? join(process.cwd(), ".next", "standalone");
53
+ const chunksRoot = opts.chunksRoot ?? join(standaloneRoot, ".next", "static", "chunks");
54
+ const markerPath = opts.markerPath ?? DEFAULT_MARKER;
55
+ const enabled = opts.enabled ?? process.env.JARELA_PREFLIGHT_OPTIMIZE_CLIENT === "1";
56
+
57
+ if (!enabled) return;
58
+ if (!existsSync(chunksRoot)) return;
59
+
60
+ let pkgVersion = "unknown";
61
+ try {
62
+ const pkg = JSON.parse(await readFile(join(process.cwd(), "package.json"), "utf8"));
63
+ pkgVersion = String(pkg.version || "unknown");
64
+ } catch {
65
+ // Non-fatal. We still optimize and stamp with "unknown".
66
+ }
67
+
68
+ if (existsSync(markerPath) && process.env.JARELA_FORCE_PREFLIGHT_OPTIMIZE !== "1") {
69
+ try {
70
+ const marker = JSON.parse(await readFile(markerPath, "utf8"));
71
+ if (marker?.version === pkgVersion) {
72
+ return;
73
+ }
74
+ } catch {
75
+ // Corrupt marker: continue and regenerate.
76
+ }
77
+ }
78
+
79
+ const terser = require("next/dist/compiled/terser");
80
+ const minify = terser?.minify;
81
+ if (typeof minify !== "function") {
82
+ console.warn("[preflight-optimize] terser unavailable; skipping optimization.");
83
+ return;
84
+ }
85
+
86
+ const jsFiles = await listJsFiles(chunksRoot);
87
+ let optimized = 0;
88
+ let skipped = 0;
89
+ let failed = 0;
90
+
91
+ for (const file of jsFiles) {
92
+ let code;
93
+ try {
94
+ code = await readFile(file, "utf8");
95
+ } catch {
96
+ failed += 1;
97
+ continue;
98
+ }
99
+
100
+ if (looksAlreadyMinified(code)) {
101
+ skipped += 1;
102
+ continue;
103
+ }
104
+
105
+ try {
106
+ const result = await minify(code, {
107
+ compress: true,
108
+ mangle: true,
109
+ sourceMap: false,
110
+ format: { comments: false },
111
+ });
112
+ if (!result?.code || result.code.length >= code.length) {
113
+ skipped += 1;
114
+ continue;
115
+ }
116
+ await writeFile(file, result.code, "utf8");
117
+ optimized += 1;
118
+ } catch {
119
+ failed += 1;
120
+ }
121
+ }
122
+
123
+ await mkdir(join(standaloneRoot, ".next", "static"), { recursive: true });
124
+ await writeFile(
125
+ markerPath,
126
+ JSON.stringify(
127
+ {
128
+ version: pkgVersion,
129
+ optimized,
130
+ skipped,
131
+ failed,
132
+ chunksRoot: normalize(relative(process.cwd(), chunksRoot)),
133
+ optimizedAt: new Date().toISOString(),
134
+ },
135
+ null,
136
+ 2,
137
+ ) + "\n",
138
+ "utf8",
139
+ );
140
+
141
+ console.log(
142
+ `[preflight-optimize] complete: optimized=${optimized}, skipped=${skipped}, failed=${failed}`,
143
+ );
144
+ }
@@ -27,4 +27,14 @@ if (process.env.JARELA_HOSTNAME) process.env.HOSTNAME = process.env.JARELA_HOSTN
27
27
  process.env.PORT ||= "4312";
28
28
  process.env.HOSTNAME ||= "127.0.0.1";
29
29
 
30
+ if (process.env.JARELA_PREFLIGHT_OPTIMIZE_CLIENT === "1") {
31
+ try {
32
+ const { optimizeClientChunksOnce } = await import("./optimize-client-chunks.mjs");
33
+ await optimizeClientChunksOnce();
34
+ } catch (error) {
35
+ const msg = error instanceof Error ? error.message : String(error);
36
+ console.warn(`[preflight-optimize] skipped: ${msg}`);
37
+ }
38
+ }
39
+
30
40
  await import(pathToFileURL(server).href);