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
51 changes: 51 additions & 0 deletions docs/api/client/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,57 @@ app.configure(
)
```

### FormData and File Uploads

The REST client automatically detects when you pass a `FormData` object and handles it appropriately - skipping JSON serialization and letting the browser set the correct `Content-Type` header with the multipart boundary.

```ts
// Create a FormData object
const formData = new FormData()
formData.append('file', fileInput.files[0])
formData.append('description', 'My uploaded file')

// Upload using the service - FormData is auto-detected
const result = await app.service('uploads').create(formData)
```

On the server, the data is parsed and converted to a plain object:

```ts
// Server receives:
{
file: File,
description: 'My uploaded file'
}
```

Multiple values for the same field name become an array:

```ts
// Client
const formData = new FormData()
formData.append('files', file1)
formData.append('files', file2)
formData.append('files', file3)

// Server receives:
{
files: [File, File, File] // All files in one array
}
```

<BlockQuote type="warning" label="REST only">

FormData and file uploads are only supported with the REST/HTTP transport. Socket.io does not support FormData - attempting to send FormData over websockets will result in an error.

</BlockQuote>

<BlockQuote type="info" label="note">

File uploads use the native `Request.formData()` API which buffers the entire request into memory. For large file uploads (videos, large datasets), consider using presigned URLs to upload directly to cloud storage (S3, R2, etc.).

</BlockQuote>

### Custom Methods

On the client, [custom service methods](../services.md#custom-methods) registered using the `methods` option when registering the service via `restClient.service()`:
Expand Down
103 changes: 0 additions & 103 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 95 additions & 5 deletions packages/feathers/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createServerAdapter } from '@whatwg-node/server'
import { createServer } from 'node:http'
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
import { TestService } from './fixture.js'

import { feathers, Application, Params } from '../src/index.js'
Expand All @@ -9,6 +8,94 @@ export * from './client.js'
export * from './rest.js'
export * from './fixture.js'

/**
* Creates a native Node.js HTTP adapter that properly converts
* IncomingMessage to a standard Request object.
* This avoids bugs in @whatwg-node/server with FormData handling.
*/
function createNativeAdapter(handler: (request: Request) => Promise<Response>) {
return async (req: IncomingMessage, res: ServerResponse) => {
// Collect body chunks
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(chunk as Buffer)
}
const body = Buffer.concat(chunks)

// Build headers object
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (value) {
if (Array.isArray(value)) {
value.forEach((v) => headers.append(key, v))
} else {
headers.set(key, value)
}
}
}

// Create the Request object
const url = `http://${req.headers.host || 'localhost'}${req.url}`
const request = new Request(url, {
method: req.method,
headers,
body: body.length > 0 ? body : undefined,
// @ts-expect-error duplex is required for streaming bodies in Node
duplex: 'half'
})

// Call the handler and get the Response
const response = await handler(request)

// Write the response
res.statusCode = response.status

response.headers.forEach((value, key) => {
res.setHeader(key, value)
})

if (response.body) {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
}

res.end()
}
}

export type UploadData = {
file?: File | File[]
files?: File | File[]
description?: string
name?: string
tags?: string | string[]
[key: string]: File | File[] | string | string[] | undefined
}

export class UploadService {
async create(data: UploadData, params: Params) {
return {
...data,
id: 1,
status: 'uploaded',
provider: params.provider
}
}

async patch(id: number | string, data: UploadData, params: Params) {
return {
...data,
id,
status: 'patched',
provider: params.provider
}
}
}

export class ResponseTestService {
async find() {
return new Response('Plain text', {
Expand Down Expand Up @@ -44,6 +131,7 @@ export class ResponseTestService {

export type TestServiceTypes = {
todos: TestService
uploads: UploadService
test: ResponseTestService
sse: SseService
}
Expand All @@ -56,6 +144,9 @@ export function getApp(): TestApplication {
app.use('todos', new TestService(), {
methods: ['find', 'get', 'create', 'update', 'patch', 'remove', 'customMethod']
})
app.use('uploads', new UploadService(), {
methods: ['create', 'patch']
})
app.use('test', new ResponseTestService())
app.use('sse', new SseService())

Expand All @@ -64,11 +155,10 @@ export function getApp(): TestApplication {

export async function createTestServer(port: number, app: TestApplication) {
const handler = createHandler(app)
// You can create your Node server instance by using our adapter
const nodeServer = createServer(createServerAdapter(handler))
// Use native Node.js adapter for proper FormData handling
const nodeServer = createServer(createNativeAdapter(handler))

await new Promise<void>((resolve) => {
// Then start listening on some port
nodeServer.listen(port, () => resolve())
})

Expand Down
1 change: 0 additions & 1 deletion packages/feathers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@
"devDependencies": {
"@types/node": "^24.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@whatwg-node/server": "^0.10.12",
"shx": "^0.4.0",
"typescript": "^5.8.0",
"vitest": "^3.2.4"
Expand Down
38 changes: 38 additions & 0 deletions packages/feathers/src/client/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,43 @@ describe('fetch REST connector', function () {
expect(messages[4]).toEqual({ message: 'Hello test 5' })
})

it('supports FormData in create', async () => {
const formData = new FormData()
formData.append('description', 'FormData test')
formData.append('name', 'test-file')

const result = await app.service('uploads').create(formData)

// Single FormData fields are unwrapped on the server
expect(result.description).toBe('FormData test')
expect(result.name).toBe('test-file')
expect(result.id).toBe(1)
expect(result.status).toBe('uploaded')
})

it('supports FormData with multiple values', async () => {
const formData = new FormData()
formData.append('tags', 'one')
formData.append('tags', 'two')
formData.append('description', 'Multi-value test')

const result = await app.service('uploads').create(formData)

// Multiple values become array, single values unwrapped
expect(result.tags).toEqual(['one', 'two'])
expect(result.description).toBe('Multi-value test')
})

it('supports FormData in patch', async () => {
const formData = new FormData()
formData.append('description', 'Patched with FormData')

const result = await app.service('uploads').patch(42, formData)

expect(result.description).toBe('Patched with FormData')
expect(result.id).toBe('42') // ID comes from URL path, returned as string
expect(result.status).toBe('patched')
})

clientTests(app, 'todos')
})
Loading