diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e4b16b8f7190..ca090bc2870d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,3 +1,4 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -94,6 +95,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(commands, ["tar"]); assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); + if ((yield* HostProcessPlatform) !== "win32") { + // The old launcher must still be able to start this archive after the + // first npm-to-executable update, including from the final directory. + yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); + yield* fs.chmod(paths.entryPath, 0o755); + const runner = yield* ProcessRunner.make(); + const legacyStart = yield* runner.run({ + command: process.execPath, + args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"], + }); + assert.equal(Number(legacyStart.code), 0, legacyStart.stderr); + assert.equal(legacyStart.stdout.trim(), "serve"); + } }), ); @@ -209,6 +223,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect("backfills a cached archive without downloading or replacing it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" }); + const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); + const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs"); + yield* fs.makeDirectory(cached.versionDir, { recursive: true }); + yield* fs.writeFileString(cached.entryPath, "cached executable\n"); + yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); + const requests: string[] = []; + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path, commands), + validate: () => + fs.exists(legacyEntry).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))), + Effect.orDie, + ), + }); + assert.deepEqual(requests, []); + assert.deepEqual(commands, []); + assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n"); + assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`); + }), + ); + it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 680d80e46cd1..ef929ebb8236 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import { CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, @@ -229,6 +230,24 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( input: PinnedRuntimeInstallInput, ) { const { fs } = input; + // Old service launchers still use the npm entry point, including when an + // archive was cached before this compatibility wrapper existed. + const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")( + function* (versionDir: string) { + const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist"); + const entryPath = input.path.join(legacyDir, "bin.mjs"); + if (yield* fs.exists(entryPath)) return; + yield* fs.makeDirectory(legacyDir, { recursive: true }); + yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive")); + }, + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "writing the legacy service entry point", + cause, + }), + ), + ); const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), @@ -242,6 +261,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { + yield* ensureLegacyEntry(paths.versionDir); yield* input.validate(paths); return paths; } @@ -290,6 +310,8 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { yield* installFromArchive(input, stagingDir); + yield* ensureLegacyEntry(stagingDir); + yield* input.validate(stagingPaths); yield* fs .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) @@ -328,7 +350,10 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ), ); - if (!published) yield* input.validate(paths); + if (!published) { + yield* ensureLegacyEntry(paths.versionDir); + yield* input.validate(paths); + } return paths; }).pipe( Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts new file mode 100644 index 000000000000..76629cbf6181 --- /dev/null +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import { withTerminalOutputWindow } from "./OutputProtocol.ts"; + +describe("terminal output window", () => { + for (const { tag, size, limit } of [ + { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, + ]) { + it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); + const output = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + + for (let index = 0; index < limit; index++) { + const value = String(index).repeat(size); + yield* Queue.offer(output, value); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 1); + assert.deepEqual(yield* Queue.take(responses), { + _tag: "Chunk", + requestId: "1", + values: [value], + }); + } + yield* Queue.offer(output, "i".repeat(size)); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Ack", requestId: "1" }); + const resumed = yield* Queue.take(responses); + assert.equal(resumed._tag, "Chunk"); + if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); + + yield* Queue.offer(output, "j"); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Interrupt", requestId: "1" }); + assert.equal((yield* Queue.take(responses))._tag, "Exit"); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + } +}); diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts new file mode 100644 index 000000000000..1bd4934f94cc --- /dev/null +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -0,0 +1,67 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type { RpcServer } from "effect/unstable/rpc"; + +const MAX_PENDING_CHUNKS = 8; +const MAX_PENDING_BYTES = 64 * 1024; +const isFull = (sizes: number[]) => + sizes.length >= MAX_PENDING_CHUNKS || + sizes.reduce((total, size) => total + size, 0) >= MAX_PENDING_BYTES; + +export function withTerminalOutputWindow( + protocol: RpcServer.Protocol["Service"], +): RpcServer.Protocol["Service"] { + const windows = new Map(); + let receive: Parameters[0]; + return { + ...protocol, + run: (write) => { + receive = write; + return protocol.run((clientId, message) => + Effect.suspend(() => { + if ( + message._tag === "Request" && + (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.subscribeTerminalEvents) + ) { + const key = `${clientId}:${message.id}`; + if (!windows.has(key)) windows.set(key, []); + } else if (message._tag === "Ack") { + const window = windows.get(`${clientId}:${message.requestId}`); + if (window) { + const wasFull = isFull(window); + window.shift(); + if (!wasFull || isFull(window)) return Effect.void; + } + } + return write(clientId, message); + }), + ); + }, + send: (clientId, response, transferables) => + Effect.suspend(() => { + const send = protocol.send(clientId, response, transferables); + if (response._tag === "Exit") { + windows.delete(`${clientId}:${response.requestId}`); + } else if (response._tag === "Chunk") { + const window = windows.get(`${clientId}:${response.requestId}`); + if (window) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const size = Buffer.byteLength(JSON.stringify(response)); + window.push(size); + if (!isFull(window)) { + return send.pipe( + Effect.andThen(() => + receive(clientId, { + _tag: "Ack", + requestId: response.requestId, + }), + ), + ); + } + } + } + return send; + }), + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0e628879712a..6a16640f7034 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -115,6 +115,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; @@ -3446,8 +3447,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); yield* analytics.record("client.connected", clientAnalyticsProps); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, + const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { + const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; + yield* RpcServer.make(WsRpcGroup, { disableTracing: true }).pipe( + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + // @effect-diagnostics-next-line returnEffectInGen:off + return httpEffect; }).pipe( Effect.provide( makeWsRpcLayer( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 574ed0f372a1..cc8e43f46738 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9364,6 +9364,7 @@ export default function ChatView(props: ChatViewProps) { {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( {image.previewUrl ? ( - + + } + /> ) : ( - {image.name} + {image.name} + + } /> ) : ( diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx new file mode 100644 index 000000000000..c803d5693009 --- /dev/null +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -0,0 +1,29 @@ +import { memo, useEffect, useState, type ReactNode } from "react"; + +import { createComposerImageThumbnail } from "../../lib/imageCompression"; + +/** Keep full-resolution image decoding out of composer rerenders. */ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ + file, + alt, + className, + fallback, +}: { + file: File; + alt: string; + className: string; + fallback: ReactNode; +}) { + const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); + useEffect(() => { + let active = true; + void createComposerImageThumbnail(file).then((src) => { + if (active) setPreview({ file, src }); + }); + return () => { + active = false; + }; + }, [file]); + const src = preview?.file === file ? preview.src : null; + return src ? {alt} : fallback; +}); diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 423c38095680..11ae6265de55 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -24,7 +24,8 @@ const style = { if (property === "user-select") this.userSelect = ""; }, }; -const setItem = vi.fn(); +const savedWidths = new Map(); +const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value)); const cancelAnimationFrame = vi.fn(); let events: EventTarget; let frame: FrameRequestCallback | undefined; @@ -40,9 +41,17 @@ function pointer(clientX = 100) { } as unknown as PointerEvent; } -function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) { +function Panel({ + edge = "left", + maxWidth = 800, + storageKey = "test-panel-width", +}: { + edge?: "left" | "right"; + maxWidth?: number; + storageKey?: string; +}) { const resize = useResizableWidth({ - storageKey: "test-panel-width", + storageKey, defaultWidth: 400, minWidth: 200, maxWidth, @@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max } beforeEach(async () => { + savedWidths.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -64,7 +74,7 @@ beforeEach(async () => { vi.stubGlobal("window", { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), - localStorage: { getItem: () => null, setItem }, + localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem }, }); vi.stubGlobal("document", { body: { style } }); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { @@ -182,3 +192,52 @@ describe("panel resize cleanup", () => { expect(captured).toBe(false); }); }); + +describe("panel width storage changes", () => { + it("restores separate thread widths without remounting and retains them after reload", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + result.handlers.onPointerUp(pointer(50)); + }); + expect(result.width).toBe(450); + await act(() => renderer.update()); + expect(result.width).toBe(400); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(-100)); + result.handlers.onPointerUp(pointer(-100)); + }); + expect(result.width).toBe(600); + await act(() => renderer.update()); + expect(result.width).toBe(450); + await act(() => renderer.unmount()); + await act(() => { + renderer = create(); + }); + expect(result.width).toBe(600); + }); + + it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => { + savedWidths.set("thread-b", "650"); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + }); + await act(() => frame?.(0)); + expect(result.width).toBe(450); + await act(() => result.handlers.onPointerMove(pointer(25))); + await act(() => renderer.update()); + expect(result.width).toBe(650); + expect(captured).toBe(false); + expect(style.cursor).toBe(""); + await act(() => { + frame?.(0); + result.handlers.onPointerUp(pointer(25)); + }); + expect(result.width).toBe(650); + expect(setItem).not.toHaveBeenCalled(); + await act(() => renderer.update()); + expect(result.width).toBe(400); + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 93ab2218d54a..22ab44bdead8 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -36,7 +36,7 @@ export interface ResizableWidthHandlers { /** * Width state for a side-anchored panel resized via a drag handle on the - * specified edge. Width is read from localStorage on mount and persisted on + * specified edge. Width is read on mount or storage-key changes and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * * The hook updates an internal `width` state during drag (so the panel @@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { ); // No cross-tab subscription: panel width is per-window state. - const [width, setWidth] = useState(() => { + const readWidth = () => { if (typeof window === "undefined") return defaultWidth; try { const stored = getLocalStorageItem(storageKey, WidthSchema); @@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not read persisted panel width.", error); return defaultWidth; } - }); + }; + const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); + // Panels stay mounted across threads; restore the destination width before paint. + if (widthState.storageKey !== storageKey) { + setWidthState({ storageKey, width: readWidth() }); + } - const clampedWidth = clamp(width); + const clampedWidth = clamp(widthState.width); const latestOptions = useRef({ clamp, storageKey }); useLayoutEffect(() => { latestOptions.current = { clamp, storageKey }; }, [clamp, storageKey]); - const handlers = useResizeDrag(() => ({ - width: clampedWidth, - edge, - resize(value) { - const nextWidth = latestOptions.current.clamp(value); - setWidth(nextWidth); - return nextWidth; - }, - finish(finalWidth) { - // Commit once at drag-end to avoid 60Hz localStorage writes. - try { - setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } - }, - })); + const handlers = useResizeDrag( + () => ({ + width: clampedWidth, + edge, + resize(value) { + const nextWidth = latestOptions.current.clamp(value); + setWidthState({ storageKey, width: nextWidth }); + return nextWidth; + }, + finish(finalWidth) { + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }, + }), + storageKey, + ); return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index e9e645436174..6509a61767d0 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -1,4 +1,4 @@ -import { type PointerEvent, useCallback, useEffect, useRef } from "react"; +import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; interface ResizeSession { width: number; @@ -11,6 +11,7 @@ interface ResizeSession { /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ export function useResizeDrag( start: (event: PointerEvent) => ResizeSession | null, + resetKey?: string, ) { const drag = useRef<{ session: ResizeSession; @@ -54,6 +55,14 @@ export function useResizeDrag( [flush], ); + const previousResetKey = useRef(resetKey); + useLayoutEffect(() => { + if (previousResetKey.current !== resetKey) { + finish(false); + previousResetKey.current = resetKey; + } + }, [finish, resetKey]); + useEffect(() => { const onBlur = () => finish(); window.addEventListener("blur", onBlur); diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 5c8952144f5a..d83bc1ef87c1 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + createComposerImageThumbnail, compressImageForStash, compressImageToByteLimit, dataUrlToFile, @@ -123,6 +124,65 @@ afterEach(() => { globalThis.OffscreenCanvas = originalOffscreenCanvas; }); +describe("composer image thumbnails", () => { + it("decodes a tall original once and caches a bounded center crop", async () => { + const close = vi.fn(); + const bitmap = { width: 2304, height: 32766, close }; + const decode = vi.fn(async () => bitmap); + const drawImage = vi.fn(); + const dimensions: number[][] = []; + vi.stubGlobal("createImageBitmap", decode); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor(width: number, height: number) { + dimensions.push([width, height]); + } + getContext() { + return { drawImage }; + } + async convertToBlob() { + return new Blob(["thumbnail"], { type: "image/png" }); + } + }, + ); + const original = new File(["original bytes"], "tall.png", { type: "image/png" }); + const [first, second] = await Promise.all([ + createComposerImageThumbnail(original), + createComposerImageThumbnail(original), + ]); + expect(first).toBe("data:image/png;base64,dGh1bWJuYWls"); + expect(second).toBe(first); + expect(await createComposerImageThumbnail(original)).toBe(first); + expect(decode).toHaveBeenCalledExactlyOnceWith(original); + expect(dimensions).toEqual([[256, 256]]); + expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256); + expect(close).toHaveBeenCalledOnce(); + expect(await original.text()).toBe("original bytes"); + }); + + it("releases the decoded image when thumbnail encoding fails", async () => { + const close = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 500, height: 500, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + getContext() { + return { drawImage: vi.fn() }; + } + async convertToBlob() { + throw new Error("encoder unavailable"); + } + }, + ); + expect(await createComposerImageThumbnail(makeFile(5))).toBeNull(); + expect(close).toHaveBeenCalledOnce(); + }); +}); + describe("dataUrlToFile", () => { it("decodes a captured image without a fetch request", async () => { const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png"); diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index af51001f7102..8414f190d5d9 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -247,6 +247,47 @@ async function encodeCanvas( return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; } +const composerThumbnails = new WeakMap>(); + +/** Cache a centered square crop for the composer's object-cover image tiles. */ +export function createComposerImageThumbnail(file: File): Promise { + const cached = composerThumbnails.get(file); + if (cached) return cached; + const thumbnail = (async () => { + if (!canRecompress()) return null; + let bitmap: ImageBitmap | undefined; + try { + bitmap = await createImageBitmap(file); + const side = Math.min(bitmap.width, bitmap.height); + if (side <= 0) return null; + const dimension = Math.min(256, side); + const surface = createCanvas(dimension, dimension); + if (!surface) return null; + surface.context.drawImage( + bitmap, + (bitmap.width - side) / 2, + (bitmap.height - side) / 2, + side, + side, + 0, + 0, + dimension, + dimension, + ); + return ( + (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? + null + ); + } catch { + return null; + } finally { + bitmap?.close(); + } + })(); + composerThumbnails.set(file, thumbnail); + return thumbnail; +} + /** * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping * quality down until the data URL fits `budgetChars`. diff --git a/packages/shared/package.json b/packages/shared/package.json index d30c26c2de79..b213261b0c3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./legacyCliLauncher": { + "types": "./src/legacyCliLauncher.ts", + "import": "./src/legacyCliLauncher.ts" + }, "./delimitedPreview": { "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts new file mode 100644 index 000000000000..0a0f3feabb76 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics nodeBuiltinImport:off - Exercises real Node IPC and process signals. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +import { legacyCliLauncherScript } from "./legacyCliLauncher.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. +const hostArch = NodeOS.arch(); + +// The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. +it.skipIf(hostPlatform === "win32").each(["npm", "archive"] as const)( + "keeps %s service IPC, arguments, and termination connected", + async (distribution) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); + const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const executable = + distribution === "archive" + ? NodePath.join(root, "t3") + : NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(NodePath.dirname(executable), "package.json"), + '{"type":"commonjs"}', + ); + await NodeFSP.writeFile(entry, legacyCliLauncherScript(distribution)); + await NodeFSP.writeFile( + executable, + `#!${process.execPath} +process.on("SIGTERM", () => process.exit(23)); +process.on("message", message => process.send({ reply: message })); +process.send({ args: process.argv.slice(2) }); +`, + ); + await NodeFSP.chmod(executable, 0o755); + const child = NodeChildProcess.fork(entry, ["serve", "a path with spaces"], { silent: true }); + try { + expect((await NodeEvents.EventEmitter.once(child, "message"))[0]).toEqual({ + args: ["serve", "a path with spaces"], + }); + const reply = NodeEvents.EventEmitter.once(child, "message"); + child.send({ type: "trial-accepted" }); + expect((await reply)[0]).toEqual({ reply: { type: "trial-accepted" } }); + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + expect(await exit).toEqual([23, null]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exit = NodeEvents.EventEmitter.once(child, "exit"); + child.kill("SIGTERM"); + await exit; + } + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts new file mode 100644 index 000000000000..f159c41d3e08 --- /dev/null +++ b/packages/shared/src/legacyCliLauncher.ts @@ -0,0 +1,36 @@ +/** Node entry point for service launchers installed before executable releases. */ +export function legacyCliLauncherScript(distribution: "npm" | "archive"): string { + const executable = + distribution === "npm" + ? 'join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName)' + : 'resolve(dirname(fileURLToPath(import.meta.url)), "../../..", executableName)'; + return `import { spawn } from "node:child_process"; +import { constants } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const executableName = process.platform === "win32" ? "t3.exe" : "t3"; +const executable = ${executable}; +const ipc = process.send !== undefined; +const child = spawn(executable, process.argv.slice(2), { + stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", +}); +const fail = (error) => { + if (!error) return; + process.stderr.write("t3: " + error.message + "\\n"); + child.kill("SIGTERM"); + process.exitCode = 1; +}; +if (ipc) { + process.on("message", (message) => { if (child.connected) child.send(message, fail); }); + child.on("message", (message) => { if (process.connected) process.send(message, fail); }); + process.on("disconnect", () => { if (child.connected) child.disconnect(); }); +} +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} +child.on("error", (error) => { fail(error); process.exit(1); }); +child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1))); +`; +} diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 2e3a35a0e9c9..47568afbcb67 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -1,3 +1,4 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -151,7 +152,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.equal(launcherManifest.name, "t3"); assert.equal(launcherManifest.version, VERSION); assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); - assert.deepStrictEqual(launcherManifest.files, ["bin"]); + assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]); assert.deepStrictEqual(launcherManifest.optionalDependencies, { "@t3code/t3-darwin-arm64": VERSION, "@t3code/t3-linux-x64": VERSION, @@ -182,13 +183,50 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { // NODE_PATH stands in for node_modules: require.resolve finds the // platform package there exactly as it would after `npm install`. + const hostPlatform = yield* HostProcessPlatform; + const hostArch = yield* HostProcessArchitecture; const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; - const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { - cwd: launcherDir, - env, - }); - assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); - assert.equal(passthrough.exitCode, 7); + if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) { + const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { + cwd: launcherDir, + env, + }); + assert.equal( + passthrough.stdout.trim(), + `stub ${hostPlatform}-${hostArch} serve --port 1234`, + ); + assert.equal(passthrough.exitCode, 7); + + // Run the entry point used by already-installed service updaters from + // the published tarball, including their preflight arguments. + const installedLauncher = path.join(fixture.root, "installed-launcher"); + yield* fs.makeDirectory(installedLauncher); + const unpack = yield* run( + "tar", + ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher], + { + cwd: fixture.root, + }, + ); + assert.equal(unpack.exitCode, 0, unpack.stderr); + const legacy = yield* run( + process.execPath, + [ + "dist/bin.mjs", + "__service-preflight", + "--database-path", + "a database.sqlite", + "--launcher-protocol", + "2", + ], + { cwd: path.join(installedLauncher, "package"), env }, + ); + assert.equal( + legacy.stdout.trim(), + `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`, + ); + assert.equal(legacy.exitCode, 7); + } const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { cwd: launcherDir, diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index f9417426b82b..f73610d10aa8 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -19,6 +19,7 @@ * bundleDependencies needs an arborist tree these flattened installs are * not), whereas `npm publish ` uploads the bytes as given. */ +import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -137,7 +138,7 @@ export function npmLauncherPackageManifest( license: serverPackageJson.license, repository: serverPackageJson.repository, bin: { t3: "./bin/t3.js" }, - files: ["bin"], + files: ["bin", "dist"], optionalDependencies: Object.fromEntries( platformKeys.map((key) => [npmPlatformPackageName(key), version]), ), @@ -342,6 +343,10 @@ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: const launcherScript = path.join(stageDir, "bin/t3.js"); yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); yield* fs.chmod(launcherScript, 0o755); + // Older service updaters and launchers run this exact path with Node. + // Keep it in the package so they can preflight and start the new executable. + yield* fs.makeDirectory(path.join(stageDir, "dist")); + yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript("npm")); const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); if (yield* fs.exists(readme)) { yield* fs.copyFile(readme, path.join(stageDir, "README.md"));