Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/sim/app/api/files/serve/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@s
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => serveLogger),
logger: serveLogger,
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
}))

const {
mockVerifyFileAccess,
mockReadFile,
Expand All @@ -18,6 +25,7 @@ const {
mockCreateFileResponse,
mockCreateErrorResponse,
FileNotFoundError,
serveLogger,
} = vi.hoisted(() => {
class FileNotFoundErrorClass extends Error {
constructor(message: string) {
Expand All @@ -26,6 +34,7 @@ const {
}
}
return {
serveLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
mockVerifyFileAccess: vi.fn(),
mockReadFile: vi.fn(),
mockIsUsingCloudStorage: vi.fn(),
Expand Down Expand Up @@ -232,4 +241,32 @@ describe('File Serve API Route', () => {
})
}
})

describe('failure log level', () => {
it('records a missing file at info, not error', async () => {
/** A superseded key is an ordinary 404, not a server fault. */
const req = new NextRequest('http://localhost:3000/api/files/serve/')
const response = await GET(req, { params: Promise.resolve({ path: [] }) })

expect(response.status).toBe(404)
expect(serveLogger.info).toHaveBeenCalledWith(
'Error serving file:',
expect.objectContaining({ reason: expect.any(String) })
)
expect(serveLogger.error).not.toHaveBeenCalled()
})

it('still records a genuine failure at error', async () => {
mockVerifyFileAccess.mockRejectedValueOnce(new Error('permission backend down'))

const req = new NextRequest(
'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt'
)
await GET(req, {
params: Promise.resolve({ path: ['workspace', 'ws', 'test-file.txt'] }),
}).catch(() => undefined)

expect(serveLogger.error).toHaveBeenCalled()
})
})
})
27 changes: 22 additions & 5 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ import {

const logger = createLogger('FilesServeAPI')

/**
* Records a failed serve at a level that matches whose fault it is.
*
* A file that is not there is an ordinary answer rather than a server fault: a
* workspace file is rewritten under a new key on every content update, so a reader
* holding the previous key lands here routinely and correctly receives a 404. Each
* handler rethrows into the outer one, so logging those at `error` reports the same
* expected 404 twice and buries the failures that do warrant attention.
*/
function logServeFailure(message: string, error: unknown): void {
if (error instanceof FileNotFoundError) {
logger.info(message, { reason: error.message })
return
}
logger.error(message, error)
}

interface ServeOptions {
/** `raw=1` — bypass all resolution and serve the stored source as-is. */
raw: boolean
Expand Down Expand Up @@ -179,7 +196,7 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
}

logger.error('Error serving file:', error)
logServeFailure('Error serving file:', error)

if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
Expand Down Expand Up @@ -244,7 +261,7 @@ async function handleLocalFile(
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
})
} catch (error) {
logger.error('Error reading local file:', error)
logServeFailure('Error reading local file:', error)
throw error
}
}
Expand Down Expand Up @@ -311,7 +328,7 @@ async function handleCloudProxy(
cacheControl: resolveServeCacheControl(options.versioned, context),
})
} catch (error) {
logger.error('Error downloading from cloud storage:', error)
logServeFailure('Error downloading from cloud storage:', error)
throw error
}
}
Expand Down Expand Up @@ -348,7 +365,7 @@ async function handleCloudProxyPublic(
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
})
} catch (error) {
logger.error('Error serving public cloud file:', error)
logServeFailure('Error serving public cloud file:', error)
throw error
}
}
Expand All @@ -373,7 +390,7 @@ async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
})
} catch (error) {
logger.error('Error reading public local file:', error)
logServeFailure('Error reading public local file:', error)
throw error
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { cn, toast } from '@sim/emcn'
import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc'
import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core'
import type { Extensions, JSONContent } from '@tiptap/core'
import { isChangeOrigin } from '@tiptap/extension-collaboration'
import { Fragment, Slice } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
Expand Down Expand Up @@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120
/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */
const DERIVE_TITLE_DEBOUNCE_MS = 600

/**
* The editor's reading column — the centered, padded surface both the live editor and the read-only
* {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder →
* live swap never reflows. Shared as one constant to keep them in lockstep.
*/
const EDITOR_SURFACE_CLASS =
'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'

/**
* Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its
* server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip
* (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live
* editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins
* (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is
* pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no
* Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document
* (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted
* only while the placeholder shows, so no second editor lingers once the live one takes over.
*/
interface ReadOnlyPlaceholderProps {
content: JSONContent
}

function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
const editor = useEditor({
extensions: EXTENSIONS,
editable: false,
// Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the
// placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame
// while the editor mounts.
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content,
editorProps: { attributes: { class: 'rich-markdown-prose' } },
})
return <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} />
}

interface RichMarkdownEditorProps {
file: WorkspaceFileRecord
workspaceId: string
Expand Down Expand Up @@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({
: parseMarkdownToDoc(splitFrontmatter(content).body)
)
/**
* A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits
* for its server seed, so the pane shows content instantly instead of blocking blank on the socket
* round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static
* HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which
* is the invariant that keeps seeding out of the client (a client seed duplicates the doc).
* The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while
* a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds
* the live editor directly, so it needs no placeholder.
*/
const [placeholderHtml] = useState<string | null>(() =>
collaborationEnabled
? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS)
: null
const [placeholderContent] = useState<JSONContent | null>(() =>
collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null
)
/**
* The body currently shown in the editor: seeded from a settled mount, updated on local edits (via
Expand Down Expand Up @@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({
if (images.length > 0) void insertImagesRef.current(images, at)
}}
/>
{showPlaceholder && placeholderHtml && (
// Instant read-only content while the collaborative doc seeds, swapped for the live editor
// once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base
// text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and
// disables ligatures), so a line wraps identically and never re-wraps on the swap.
<div
className='ProseMirror rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6'
dangerouslySetInnerHTML={{ __html: placeholderHtml }}
/>
{showPlaceholder && placeholderContent && (
<ReadOnlyPlaceholder content={placeholderContent} />
)}
<EditorContent
editor={editor}
className={cn(
'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white',
showPlaceholder && placeholderHtml && 'hidden'
)}
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
/>
</div>
)
Expand Down
Loading
Loading