@k2b/cloud 0.37.0 → 0.39.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.
Files changed (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -12,7 +12,7 @@ import { allowedMailProfile, type MessageRow, messageAttachmentRefs, messageReco
12
12
  import { OutgoingMailError, resolveMailCredentials } from "./store";
13
13
  import { mailAttachments, mailSettled } from "./sync";
14
14
  import { smtpFailureMessage } from "./test-send";
15
- import { buildMailTransport } from "./transport";
15
+ import { resolveMailTransport } from "./transport";
16
16
 
17
17
  export const cleanupMailAttachments = async (id: string): Promise<void> => {
18
18
  const row = await outgoingMailMessages.read(id);
@@ -136,7 +136,12 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
136
136
  socket.once("connect", () => {
137
137
  if (attempt.signal.aborted) socket.destroy();
138
138
  });
139
- const transport = buildMailTransport(credentials, socket);
139
+ const resolved = await resolveMailTransport(credentials, attempt.signal).catch((error: unknown) => {
140
+ socket.destroy();
141
+ throw error;
142
+ });
143
+ // Nodemailer cannot fall back across IP literals; each queued retry rotates to the next checked address.
144
+ const transport = resolved.build(Math.max(0, row.attempt_count - 1), socket);
140
145
  const stop = () => {
141
146
  socket.destroy();
142
147
  transport.close();
@@ -0,0 +1,39 @@
1
+ import { env } from "../../config/env";
2
+ import { networkAddressFamily, normalizeNetworkHostname, parseNetworkAllowlistEnv } from "../../shared/network-address";
3
+ import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../network-security";
4
+
5
+ export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
6
+
7
+ /** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
8
+ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses) => {
9
+ const hostname = normalizeNetworkHostname(host);
10
+ const deadline = new AbortController();
11
+ const timer = setTimeout(() => deadline.abort(new Error("Outgoing mail DNS lookup timed out.")), 5000);
12
+ const cancellation = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
13
+ let abort: (() => void) | undefined;
14
+ try {
15
+ cancellation.throwIfAborted();
16
+ const addresses = await Promise.race([
17
+ resolve(hostname, outgoingMailAllowlist()),
18
+ new Promise<never>((_, reject) => {
19
+ abort = () => reject(cancellation.reason);
20
+ cancellation.addEventListener("abort", abort, { once: true });
21
+ if (cancellation.aborted) abort();
22
+ }),
23
+ ]);
24
+ return {
25
+ host: hostname,
26
+ addresses: [...addresses.filter((entry) => entry.family === 4), ...addresses.filter((entry) => entry.family === 6)],
27
+ servername: networkAddressFamily(hostname) ? undefined : hostname,
28
+ };
29
+ } catch (error) {
30
+ if (error instanceof NetworkTargetBlockedError)
31
+ throw new NetworkTargetBlockedError(
32
+ "Outgoing mail target is on an internal network. An operator can allow it with OUTGOING_MAIL_ALLOWED_TARGETS.",
33
+ );
34
+ throw error;
35
+ } finally {
36
+ clearTimeout(timer);
37
+ if (abort) cancellation.removeEventListener("abort", abort);
38
+ }
39
+ };
@@ -1,7 +1,7 @@
1
1
  import { audit } from "../audit";
2
2
  import { get } from "../settings";
3
3
  import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
- import { buildMailTransport } from "./transport";
4
+ import { resolveMailTransport } from "./transport";
5
5
 
6
6
  export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
7
7
  let message = error instanceof Error ? error.message : "SMTP delivery failed.";
@@ -17,8 +17,23 @@ export const outgoingMailTest = {
17
17
  const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
18
18
  // Fail before the external effect if audit storage is unavailable.
19
19
  await audit.record({ ...event, outcome: "allowed" });
20
- const transport = buildMailTransport(profile);
20
+ let transport: ReturnType<Awaited<ReturnType<typeof resolveMailTransport>>["build"]> | undefined;
21
21
  try {
22
+ const resolved = await resolveMailTransport(profile);
23
+ for (let index = 0; index < resolved.count; index++) {
24
+ transport = resolved.build(index);
25
+ try {
26
+ // CONN also labels socket errors after DATA. Only fall back during a
27
+ // preflight that cannot send a message, then send once to that address.
28
+ await transport.verify();
29
+ break;
30
+ } catch (error) {
31
+ transport.close();
32
+ transport = undefined;
33
+ if (!(error instanceof Error) || !("command" in error) || error.command !== "CONN" || index === resolved.count - 1) throw error;
34
+ }
35
+ }
36
+ if (!transport) throw new Error("No validated SMTP address.");
22
37
  await transport.sendMail({
23
38
  from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
24
39
  to: recipient,
@@ -34,7 +49,7 @@ export const outgoingMailTest = {
34
49
  await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
35
50
  throw new OutgoingMailError("smtp_failed", message, 502);
36
51
  } finally {
37
- transport.close();
52
+ transport?.close();
38
53
  }
39
54
  },
40
55
  };
@@ -1,17 +1,28 @@
1
1
  import type { Socket } from "node:net";
2
2
  import { createTransport } from "nodemailer";
3
+ import { resolveMailHost } from "./resolve-host";
3
4
  import type { resolveMailCredentials } from "./store";
4
5
 
5
- export const buildMailTransport = (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, socket?: Socket) =>
6
- createTransport({
7
- // Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
8
- // still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
9
- ...(socket ? { socket } : {}),
10
- host: profile.smtpHost,
11
- port: profile.smtpPort,
12
- secure: profile.smtpSecure,
13
- ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
14
- connectionTimeout: 10000,
15
- greetingTimeout: 10000,
16
- socketTimeout: 60000,
17
- });
6
+ export const resolveMailTransport = async (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, signal?: AbortSignal) => {
7
+ const target = await resolveMailHost(profile.smtpHost, signal);
8
+ signal?.throwIfAborted();
9
+ const count = target.addresses.length;
10
+ return {
11
+ count,
12
+ build: (index: number, socket?: Socket) =>
13
+ createTransport({
14
+ // Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
15
+ // still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
16
+ ...(socket ? { socket } : {}),
17
+ host: target.addresses[Math.max(0, index) % count]!.address,
18
+ servername: target.servername,
19
+ tls: { servername: target.servername },
20
+ port: profile.smtpPort,
21
+ secure: profile.smtpSecure,
22
+ ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
23
+ connectionTimeout: 10000,
24
+ greetingTimeout: 10000,
25
+ socketTimeout: 60000,
26
+ }),
27
+ };
28
+ };
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Bun already reserves about 270..296 MiB of the 512 MiB data limit. A single 32 MiB input plus 64 MiB RGBA
5
+ * leaves about 120..146 MiB for codecs and temporary buffers. Notebooks defaults to 10 MB attachments.
6
+ */
7
+ export const PDF_IMAGE_MAX_INPUT_BYTES = 32 * 1024 * 1024;
8
+ /** Bound the number of encoders and the output envelope per call. */
9
+ export const PDF_IMAGE_MAX_BOXES = 8;
10
+ export const ImageBox = z.object({ width: z.number().int().min(1).max(16383), height: z.number().int().min(1).max(16383) });
11
+ export const ImageRequest = z.object({
12
+ boxes: z.array(ImageBox).min(1).max(PDF_IMAGE_MAX_BOXES),
13
+ maxBytes: z.number().int().min(1).max(PDF_IMAGE_MAX_INPUT_BYTES),
14
+ // 240 s is the request idle-timeout ceiling; 30 s matches the PDF page decoder.
15
+ timeoutMs: z.number().int().min(1).max(240_000).default(30_000),
16
+ });
17
+ export const ImageErrorCode = z.enum(["unsupported_format", "unreadable", "too_large", "timed_out"]);
18
+ export const ImageVariantHeader = z.object({
19
+ type: z.enum(["image/jpeg", "image/png"]),
20
+ width: z.number().int().min(1).max(16383),
21
+ height: z.number().int().min(1).max(16383),
22
+ bytes: z.number().int().min(1).max(PDF_IMAGE_MAX_INPUT_BYTES),
23
+ });
24
+ export const ImageResponse = z.discriminatedUnion("ok", [
25
+ z.strictObject({ ok: z.literal(true) }),
26
+ z.strictObject({ ok: z.literal(false), code: ImageErrorCode }),
27
+ ]);
28
+
29
+ const ImageFrameHeader = ImageRequest.extend({ bytes: z.number().int().min(0).max(PDF_IMAGE_MAX_INPUT_BYTES) });
30
+
31
+ const ImageRecord = z.union([ImageVariantHeader.extend({ index: z.number().int().min(0) }).strict(), ImageResponse]);
32
+ type ImageVariant = { data: Blob; type: "image/jpeg" | "image/png"; width: number; height: number };
33
+
34
+ /** Read bounded variant records and their exact bodies, followed by one final status and EOF. */
35
+ export async function readImageRecords(
36
+ stream: ReadableStream<Uint8Array>,
37
+ request: Pick<z.infer<typeof ImageRequest>, "boxes" | "maxBytes">,
38
+ ) {
39
+ const reader = stream.getReader();
40
+ let chunk: Uint8Array = new Uint8Array();
41
+ let offset = 0;
42
+ const more = async () => {
43
+ while (offset === chunk.byteLength) {
44
+ const { done, value } = await reader.read();
45
+ if (done) return false;
46
+ chunk = value;
47
+ offset = 0;
48
+ }
49
+ return true;
50
+ };
51
+ const variants: (ImageVariant | null)[] = request.boxes.map(() => null);
52
+ let count = 0;
53
+ let total = 0;
54
+ try {
55
+ for (;;) {
56
+ const line: Uint8Array[] = [];
57
+ let length = 0;
58
+ for (;;) {
59
+ if (!(await more())) throw new Error("Missing image record.");
60
+ const newline = chunk.indexOf(10, offset);
61
+ const end = newline < 0 ? chunk.byteLength : newline;
62
+ length += end - offset;
63
+ if (length > 4096) throw new Error("Image record exceeds 4 KiB.");
64
+ line.push(chunk.subarray(offset, end));
65
+ offset = end + (newline < 0 ? 0 : 1);
66
+ if (newline >= 0) break;
67
+ }
68
+ const record = ImageRecord.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(line, length))));
69
+ if ("ok" in record) {
70
+ if (await more()) throw new Error("Unexpected data after final image record.");
71
+ return record.ok ? { ok: true as const, variants } : record;
72
+ }
73
+ const box = request.boxes[record.index];
74
+ total += record.bytes;
75
+ if (
76
+ ++count > request.boxes.length ||
77
+ !box ||
78
+ variants[record.index] !== null ||
79
+ record.bytes > request.maxBytes ||
80
+ record.width > box.width ||
81
+ record.height > box.height ||
82
+ total > request.boxes.length * request.maxBytes
83
+ ) {
84
+ throw new Error("Image variant exceeds its bounds or repeats an index.");
85
+ }
86
+ const body = new Uint8Array(record.bytes);
87
+ let written = 0;
88
+ while (written < body.byteLength) {
89
+ if (!(await more())) throw new Error("Truncated image variant.");
90
+ const size = Math.min(body.byteLength - written, chunk.byteLength - offset);
91
+ body.set(chunk.subarray(offset, offset + size), written);
92
+ written += size;
93
+ offset += size;
94
+ }
95
+ variants[record.index] = {
96
+ data: new Blob([body], { type: record.type }),
97
+ type: record.type,
98
+ width: record.width,
99
+ height: record.height,
100
+ };
101
+ }
102
+ } catch (error) {
103
+ await reader.cancel().catch(() => {});
104
+ throw error;
105
+ } finally {
106
+ reader.releaseLock();
107
+ }
108
+ }
109
+
110
+ /** Read a 4 KiB JSON header, then fill one allocation of exactly the declared input length. */
111
+ export function readImageFrame(read: (buffer: Uint8Array, offset: number, length: number) => number) {
112
+ // Async stdin chunks made worker memory nondeterministic under its data limit, so read synchronously.
113
+ const headerBuffer = new Uint8Array(4097);
114
+ let headerBytes = 0;
115
+ let newline = -1;
116
+ while (newline < 0) {
117
+ const size = read(headerBuffer, headerBytes, headerBuffer.byteLength - headerBytes);
118
+ if (size === 0) throw new Error("Missing image frame header.");
119
+ newline = headerBuffer.subarray(0, headerBytes + size).indexOf(10, headerBytes);
120
+ headerBytes += size;
121
+ if (newline < 0 && headerBytes === headerBuffer.byteLength) throw new Error("Image frame header exceeds its limit.");
122
+ }
123
+ const header = ImageFrameHeader.parse(JSON.parse(Buffer.from(headerBuffer.buffer, 0, newline).toString("utf8")));
124
+ const body = new Uint8Array(header.bytes);
125
+ let bodyBytes = headerBytes - newline - 1;
126
+ if (bodyBytes > body.byteLength) throw new Error("Image frame body exceeds its declared length.");
127
+ body.set(headerBuffer.subarray(newline + 1, headerBytes));
128
+ while (bodyBytes < body.byteLength) {
129
+ const size = read(body, bodyBytes, body.byteLength - bodyBytes);
130
+ if (size === 0) throw new Error("Truncated image frame body.");
131
+ bodyBytes += size;
132
+ }
133
+ if (read(headerBuffer, 0, 1) !== 0) throw new Error("Image frame body exceeds its declared length.");
134
+ return { header, body };
135
+ }
@@ -0,0 +1,124 @@
1
+ import sharp from "sharp";
2
+ import type { z } from "zod";
3
+ import type { ImageRequest, ImageResponse, ImageVariantHeader } from "./image-protocol";
4
+
5
+ sharp.concurrency(1);
6
+ sharp.cache(false);
7
+
8
+ /**
9
+ * 16 MiP is 64 MiB RGBA; A4 at 300 dpi needs about 5.8 MP. With 270..296 MiB for Bun and 32 MiB for input,
10
+ * about 120..146 MiB of the 512 MiB limit remains for codecs and temporary buffers. Some source decodes need more.
11
+ */
12
+ const MAX_DECODED_PIXELS = 16 * 1024 * 1024;
13
+ const MAX_SOURCE_PIXELS = 268402689; // sharp's unchanged default limitInputPixels.
14
+ // Measured RGBA WebP peaks are non-monotonic: 440..473 MiB at 16 MiP, 476..495 at 24 MiP, 429..457 at 32 MiP.
15
+ // Cap full-image decoders at 16 MiP to leave about 40 MiB below the worker's 512 MiB data limit.
16
+ const MAX_FULL_DECODE_SOURCE_PIXELS = 16 * 1024 * 1024;
17
+
18
+ const fit = (width: number, height: number, box: { width: number; height: number }) => {
19
+ const scale = Math.min(1, box.width / width, box.height / height);
20
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
21
+ };
22
+
23
+ /** Worker-only: validate pass-throughs, decode once for resized variants, then emit encodings sequentially. */
24
+ export async function prepareImageVariants(
25
+ source: Uint8Array | undefined,
26
+ request: z.infer<typeof ImageRequest>,
27
+ write: (index: number, header: z.infer<typeof ImageVariantHeader>, bytes: Uint8Array) => Promise<void>,
28
+ ): Promise<z.infer<typeof ImageResponse>> {
29
+ let identified = false;
30
+ try {
31
+ const metadata = await sharp(source).metadata();
32
+ identified = true;
33
+ if (
34
+ !["jpeg", "png", "webp", "gif", "tiff"].includes(metadata.format) &&
35
+ !(metadata.format === "heif" && metadata.compression === "av1")
36
+ ) {
37
+ return { ok: false, code: "unsupported_format" };
38
+ }
39
+ const width = metadata.width;
40
+ const height = metadata.pageHeight ?? metadata.height;
41
+ if (width * height > MAX_SOURCE_PIXELS) return { ok: false, code: "too_large" };
42
+ if (["webp", "gif", "tiff", "heif"].includes(metadata.format) && width * height > MAX_FULL_DECODE_SOURCE_PIXELS) {
43
+ return { ok: false, code: "too_large" };
44
+ }
45
+ const rotated = (metadata.orientation ?? 1) >= 5;
46
+ const upright = { width: rotated ? height : width, height: rotated ? width : height };
47
+ const sizes = request.boxes.map((box) => fit(upright.width, upright.height, box));
48
+ const passthrough = request.boxes.map(
49
+ (box) =>
50
+ metadata.format === "jpeg" &&
51
+ (metadata.orientation === undefined || metadata.orientation === 1) &&
52
+ (metadata.space === "srgb" || metadata.space === "b-w") &&
53
+ // `space` describes the pixels, not the profile: a Display P3 or Adobe RGB JPEG also reports srgb.
54
+ !metadata.hasProfile &&
55
+ width <= box.width &&
56
+ height <= box.height &&
57
+ source!.byteLength <= request.maxBytes,
58
+ );
59
+ // Metadata alone accepts truncated JPEGs. Validate once with default failOn before emitting any unchanged bytes.
60
+ if (passthrough.some(Boolean)) await sharp(source).resize(8, 8, { fit: "inside" }).toBuffer();
61
+ for (let index = 0; index < sizes.length; index++) {
62
+ if (!passthrough[index]) continue;
63
+ await write(index, { type: "image/jpeg", width, height, bytes: source!.byteLength }, source!);
64
+ }
65
+ const needed = sizes.flatMap((_, index) => (passthrough[index] ? [] : [index]));
66
+ if (!needed.length) return { ok: true };
67
+ const largestIndex = needed.reduce((a, b) => (sizes[a]!.width * sizes[a]!.height >= sizes[b]!.width * sizes[b]!.height ? a : b));
68
+ const largest = sizes[largestIndex]!;
69
+ if (largest.width * largest.height > MAX_DECODED_PIXELS) return { ok: false, code: "too_large" };
70
+ const decoded = await sharp(source)
71
+ // Fit the original box once: fitting already rounded dimensions can shrink an extra pixel.
72
+ .resize({ ...request.boxes[largestIndex]!, fit: "inside", withoutEnlargement: true })
73
+ // autoOrient before resize disables shrink-on-load.
74
+ .autoOrient()
75
+ .toColourspace("srgb")
76
+ .raw({ depth: "uchar" })
77
+ .toBuffer({ resolveWithObject: true });
78
+ source = undefined;
79
+ const { data, info } = decoded;
80
+ let transparent = false;
81
+ if (info.channels === 2 || info.channels === 4) {
82
+ for (let offset = info.channels - 1; offset < data.length; offset += info.channels) {
83
+ if (data[offset] !== 255) {
84
+ transparent = true;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ for (let index = 0; index < sizes.length; index++) {
90
+ if (passthrough[index]) continue;
91
+ const size = sizes[index]!;
92
+ let image = sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } });
93
+ if (size.width !== info.width || size.height !== info.height) image = image.resize(size.width, size.height, { fit: "fill" });
94
+ const type = transparent ? "image/png" : "image/jpeg";
95
+ let encoded: Buffer | undefined = await (transparent
96
+ ? image.png()
97
+ : image.removeAlpha().jpeg({ quality: 85, mozjpeg: true })
98
+ ).toBuffer();
99
+ if (encoded.byteLength <= request.maxBytes) {
100
+ await write(index, { ...size, type, bytes: encoded.byteLength }, encoded);
101
+ }
102
+ encoded = undefined;
103
+ }
104
+ return { ok: true };
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
107
+ // Codec allocation errors can also mention a corrupt header or unsupported format. Memory takes precedence.
108
+ // Thread stacks count against the data limit.
109
+ // Unidentified byte streams are unreadable; a recognized but missing codec is unsupported.
110
+ const code = [
111
+ "out of memory",
112
+ "insufficient memory",
113
+ "memory allocation",
114
+ "cannot allocate",
115
+ "pixel limit",
116
+ "error creating thread",
117
+ ].some((part) => message.includes(part))
118
+ ? "too_large"
119
+ : (identified && message.includes("unsupported image format")) || message.includes("compression format has not been built in")
120
+ ? "unsupported_format"
121
+ : "unreadable";
122
+ return { ok: false, code };
123
+ }
124
+ }
@@ -0,0 +1,31 @@
1
+ // Standalone subprocess. No server imports or application credentials.
2
+ import { readSync } from "node:fs";
3
+ import { readImageFrame } from "./image-protocol";
4
+ import { prepareImageVariants } from "./image-variants";
5
+
6
+ const write = (chunk: string | Uint8Array) =>
7
+ new Promise<void>((resolve, reject) => {
8
+ process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()));
9
+ });
10
+ // Timers bound processing. During blocking stdin reads, a crashed parent closes the pipe;
11
+ // a live parent enforces its deadline with SIGKILL.
12
+ const expire = () => process.exit(1);
13
+ let timer = setTimeout(expire, 240_000);
14
+ try {
15
+ let frame: ReturnType<typeof readImageFrame> | undefined = readImageFrame((buffer, offset, length) =>
16
+ readSync(0, buffer, offset, length, null),
17
+ );
18
+ const request = frame.header;
19
+ clearTimeout(timer);
20
+ timer = setTimeout(expire, request.timeoutMs);
21
+ const pending = prepareImageVariants(frame.body, request, async (index, header, bytes) => {
22
+ await write(`${JSON.stringify({ index, ...header })}\n`);
23
+ await write(bytes);
24
+ });
25
+ frame = undefined;
26
+ await write(`${JSON.stringify(await pending)}\n`);
27
+ } catch {
28
+ await write(`${JSON.stringify({ ok: false, code: "unreadable" })}\n`).catch(() => {});
29
+ } finally {
30
+ clearTimeout(timer);
31
+ }
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { spawnCanvasWorker } from "../../_internal/canvas-worker";
3
+ import { type ImageBox, type ImageErrorCode, ImageRequest, PDF_IMAGE_MAX_INPUT_BYTES, readImageRecords } from "./image-protocol";
4
+
5
+ export { PDF_IMAGE_MAX_BOXES, PDF_IMAGE_MAX_INPUT_BYTES } from "./image-protocol";
6
+ /** Integer pixels, 1..16383 per side: a square stays within sharp's default input pixel limit. */
7
+ export type PdfImageBox = z.infer<typeof ImageBox>;
8
+ export type PdfImageVariant = { data: Blob; type: "image/jpeg" | "image/png"; width: number; height: number };
9
+ export type PdfImageErrorCode = z.infer<typeof ImageErrorCode>;
10
+ export type PreparePdfImageInput = { data: Uint8Array | Blob; boxes: PdfImageBox[]; maxBytes: number };
11
+ /** The deadline includes queue time. Default 30 s matches PDF page decoding; 240 s is the request idle-timeout ceiling. */
12
+ export type PreparePdfImageOptions = { signal?: AbortSignal; timeoutMs?: number };
13
+ export type PreparePdfImageResult = { ok: true; variants: (PdfImageVariant | null)[] } | { ok: false; code: PdfImageErrorCode };
14
+
15
+ declare const __CLOUD_PDF_IMAGE_WORKER__: string | undefined;
16
+ let activeWorkers = 0;
17
+ const waiting: (() => void)[] = [];
18
+
19
+ async function acquire(signal: AbortSignal) {
20
+ if (signal.aborted) return false;
21
+ if (activeWorkers < 2) {
22
+ activeWorkers++;
23
+ return true;
24
+ }
25
+ return new Promise<boolean>((resolve) => {
26
+ const abort = () => {
27
+ waiting.splice(waiting.indexOf(start), 1);
28
+ resolve(false);
29
+ };
30
+ const start = () => {
31
+ signal.removeEventListener("abort", abort);
32
+ resolve(true);
33
+ };
34
+ waiting.push(start);
35
+ signal.addEventListener("abort", abort, { once: true });
36
+ });
37
+ }
38
+
39
+ function release() {
40
+ const next = waiting.shift();
41
+ if (next) next();
42
+ else activeWorkers--;
43
+ }
44
+
45
+ /**
46
+ * Fit raw image bytes into 1..8 pixel boxes, without enlargement, in one isolated Linux worker (at most two at once).
47
+ * Opaque images use sRGB JPEG q85; transparency uses PNG. Fitting upright sRGB JPEGs pass through unchanged after a validation decode.
48
+ * Results follow box order; null means that encoding exceeded maxBytes. Invalid arguments throw; image failures return codes.
49
+ * Input is capped at 32 MiB; the largest decoded variant at 16 MiP (64 MiB RGBA, versus about 5.8 MP for A4 at 300 dpi).
50
+ * WebP, GIF, TIFF and AVIF sources are capped at 16 MiP (about 16.8 MP).
51
+ * Bun itself uses about half the 512 MiB data limit; decodes that exhaust the remaining memory return too_large.
52
+ */
53
+ export async function preparePdfImage(input: PreparePdfImageInput, options: PreparePdfImageOptions = {}): Promise<PreparePdfImageResult> {
54
+ const request = ImageRequest.parse({ boxes: input.boxes, maxBytes: input.maxBytes, timeoutMs: options.timeoutMs });
55
+ const inputData = z.union([z.instanceof(Uint8Array), z.instanceof(Blob)]).parse(input.data);
56
+ if (process.platform !== "linux") throw new Error("PDF image preparation requires the Linux Cloud runtime with memory isolation.");
57
+ if (options.signal?.aborted) return { ok: false, code: "timed_out" };
58
+ if ((input.data instanceof Blob ? input.data.size : input.data.byteLength) > PDF_IMAGE_MAX_INPUT_BYTES) {
59
+ return { ok: false, code: "too_large" };
60
+ }
61
+ const deadline = Date.now() + request.timeoutMs;
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
64
+ const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
65
+ let acquired = false;
66
+ try {
67
+ acquired = await acquire(signal);
68
+ if (!acquired || signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
69
+ const worker =
70
+ typeof __CLOUD_PDF_IMAGE_WORKER__ === "string"
71
+ ? new URL(__CLOUD_PDF_IMAGE_WORKER__, import.meta.url)
72
+ : new URL("./image-worker.ts", import.meta.url);
73
+ // Bun's file-backed Blobs must be materialized before composing a multipart Blob. A slow read must not keep the
74
+ // slot past the deadline, so the abort settles the wait while the read finishes on its own.
75
+ const data =
76
+ inputData instanceof Blob
77
+ ? new Uint8Array(
78
+ await new Promise<ArrayBuffer>((resolve, reject) => {
79
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
80
+ inputData.arrayBuffer().then(resolve, reject);
81
+ }),
82
+ )
83
+ : inputData;
84
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
85
+ if (data.byteLength > PDF_IMAGE_MAX_INPUT_BYTES) return { ok: false, code: "too_large" };
86
+ const header = `${JSON.stringify({ ...request, bytes: data.byteLength, timeoutMs: Math.max(1, deadline - Date.now()) })}\n`;
87
+ const child = spawnCanvasWorker(worker, "pdf-image", new Blob([header, data]));
88
+ const stop = () => child.kill("SIGKILL");
89
+ signal.addEventListener("abort", stop, { once: true });
90
+ if (signal.aborted) stop();
91
+ try {
92
+ const result = await readImageRecords(child.stdout, request);
93
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
94
+ const exitCode = await child.exited;
95
+ if (signal.aborted || Date.now() >= deadline) return { ok: false, code: "timed_out" };
96
+ if (exitCode !== 0) return { ok: false, code: "unreadable" };
97
+ return result;
98
+ } finally {
99
+ signal.removeEventListener("abort", stop);
100
+ stop();
101
+ await child.exited;
102
+ }
103
+ } catch {
104
+ return { ok: false, code: signal.aborted || Date.now() >= deadline ? "timed_out" : "unreadable" };
105
+ } finally {
106
+ clearTimeout(timer);
107
+ if (acquired) release();
108
+ }
109
+ }
@@ -21,6 +21,15 @@ export {
21
21
  renderHtmlToPdfWithConfig,
22
22
  testGotenberg,
23
23
  } from "./gotenberg";
24
+ export type {
25
+ PdfImageBox,
26
+ PdfImageErrorCode,
27
+ PdfImageVariant,
28
+ PreparePdfImageInput,
29
+ PreparePdfImageOptions,
30
+ PreparePdfImageResult,
31
+ } from "./image";
32
+ export { PDF_IMAGE_MAX_BOXES, PDF_IMAGE_MAX_INPUT_BYTES, preparePdfImage } from "./image";
24
33
  export type {
25
34
  BuildPresetPdfHtmlInput,
26
35
  MarkdownPdfErrorCode,
@@ -12,13 +12,14 @@
12
12
  * `services/weather/forecast.ts`, OpenAPI middleware, cron callbacks).
13
13
  * Loosely typed (caller provides the value type via the `T` generic).
14
14
  *
15
- * Backend: cache-aside via `store.ts` (Redis 5min TTL + DB fallback). All
16
- * functions are async — sync render-time access uses the per-request snapshot
15
+ * Backend: value reads use cache-aside via `store.ts` (Redis 5min TTL + DB
16
+ * fallback); stored-state reads use Postgres. All functions are async — sync
17
+ * render-time access uses the per-request snapshot
17
18
  * exposed via `c.get("settings")`.
18
19
  */
19
20
 
20
21
  import type { AppSettingsMap, KindToType } from "../../contracts/settings-types";
21
- import { deleteKey, readKey, writeKey } from "./store";
22
+ import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } from "./store";
22
23
 
23
24
  /**
24
25
  * Per-app typed settings API. Keys constrained to those declared in the
@@ -26,7 +27,8 @@ import { deleteKey, readKey, writeKey } from "./store";
26
27
  */
27
28
  export interface SettingsAPI<F extends Record<string, unknown>> {
28
29
  get<K extends keyof F & string>(key: K): Promise<F[K]>;
29
- set<K extends keyof F & string>(key: K, value: F[K]): Promise<void>;
30
+ read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
31
+ set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
30
32
  remove<K extends keyof F & string>(key: K): Promise<void>;
31
33
  }
32
34
 
@@ -40,7 +42,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
40
42
  [K in keyof S]: KindToType<S[K]["kind"]>;
41
43
  }> => ({
42
44
  get: async (key) => readKey(key) as never,
43
- set: async (key, value) => writeKey(key, value),
45
+ read: async (key) => readKeyState(key) as never,
46
+ set: async (key, value, options) => writeKey(key, value, undefined, options),
44
47
  remove: async (key) => deleteKey(key),
45
48
  });
46
49
 
@@ -56,6 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
56
59
  */
57
60
  export const coreSettings = {
58
61
  get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
59
- set: (key: string, value: unknown): Promise<void> => writeKey(key, value),
62
+ read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
63
+ readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
64
+ set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
60
65
  remove: (key: string): Promise<void> => deleteKey(key),
61
66
  };
@@ -13,6 +13,7 @@ import { ImageMetadataError } from "../image-metadata";
13
13
  import type { SettingEntry } from ".";
14
14
  import * as settingsPrimitives from ".";
15
15
  import { SETTINGS_MAP, validateSettingValue } from "./defaults";
16
+ import { SettingUnreadableError } from "./store";
16
17
 
17
18
  /**
18
19
  * Redact secret-kind setting values before they leave the server.
@@ -54,7 +55,7 @@ export const settingsService = {
54
55
 
55
56
  return paginateItems(filtered, config?.pagination);
56
57
  },
57
- update: async (config: { key: string; value: unknown }) => {
58
+ update: async (config: { key: string; value: unknown; replaceUnreadable?: boolean }) => {
58
59
  if (!SETTINGS_MAP.has(config.key)) {
59
60
  return fail(err.badInput(`Unknown setting: ${config.key}`));
60
61
  }
@@ -64,11 +65,11 @@ export const settingsService = {
64
65
  return fail(err.badInput(validated.error));
65
66
  }
66
67
  try {
67
- await settingsPrimitives.set(config.key, validated.value);
68
+ await settingsPrimitives.set(config.key, validated.value, undefined, { replaceUnreadable: config.replaceUnreadable });
68
69
  return ok(undefined);
69
70
  } catch (error) {
70
- // A Result cannot carry 422; the typed HTTPException keeps its status through Hono.
71
- if (error instanceof ImageMetadataError) throw error;
71
+ // Preserve the typed HTTP status and recovery code through Hono.
72
+ if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError) throw error;
72
73
  const message = error instanceof Error ? error.message : String(error);
73
74
  if (message.startsWith("Unknown setting:")) {
74
75
  return fail(err.badInput(message));