// Copyright (c) 2023 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 import assert from 'node:assert'; import util from 'node:util'; let scheduledLastCtrl; export default { async fetch(request, env, ctx) { const { pathname } = new URL(request.url); if (pathname === '/body-length') { return Response.json(Object.fromEntries(request.headers)); } if (pathname === '/ray-id') { return new Response(null, { status: 200, headers: { 'CF-Ray': 'test-ray-id-123' }, }); } if (pathname === '/web-socket') { const pair = new WebSocketPair(); pair[0].addEventListener('message', (event) => { pair[0].send(util.inspect(event)); }); pair[0].accept(); return new Response(null, { status: 101, webSocket: pair[1], }); } return new Response(null, { status: 404 }); }, async scheduled(ctrl, env, ctx) { scheduledLastCtrl = ctrl; if (ctrl.cron === '* * * * 30') ctrl.noRetry(); }, async test(ctrl, env, ctx) { // Call `fetch()` with known body length { const body = new FixedLengthStream(3); const writer = body.writable.getWriter(); void writer.write(new Uint8Array([1, 2, 3])); void writer.close(); const response = await env.SERVICE.fetch( 'http://placeholder/body-length', { method: 'POST', body: body.readable, } ); const headers = new Headers(await response.json()); assert.strictEqual(headers.get('Content-Length'), '3'); assert.strictEqual(headers.get('Transfer-Encoding'), null); } // Check `fetch()` with unknown body length { const body = new IdentityTransformStream(); const writer = body.writable.getWriter(); void writer.write(new Uint8Array([1, 2, 3])); void writer.close(); const response = await env.SERVICE.fetch( 'http://placeholder/body-length', { method: 'POST', body: body.readable, } ); const headers = new Headers(await response.json()); assert.strictEqual(headers.get('Content-Length'), null); assert.strictEqual(headers.get('Transfer-Encoding'), 'chunked'); } // Call `fetch()` with GET to endpoint that returns CF-Ray header, verifying // cloudflare.ray_id is set on the trace span from the response headers. { const response = await env.SERVICE.fetch('http://placeholder/ray-id'); assert.strictEqual(response.status, 200); assert.strictEqual(response.headers.get('CF-Ray'), 'test-ray-id-123'); } // Call `scheduled()` with no options { const result = await env.SERVICE.scheduled(); assert.strictEqual(result.outcome, 'ok'); assert(!result.noRetry); assert(Math.abs(Date.now() - scheduledLastCtrl.scheduledTime) < 3_000); assert.strictEqual(scheduledLastCtrl.cron, ''); } // Call `scheduled()` with options, and noRetry() { const result = await env.SERVICE.scheduled({ scheduledTime: 1000, cron: '* * * * 30', }); assert.strictEqual(result.outcome, 'ok'); assert(result.noRetry); assert.strictEqual(scheduledLastCtrl.scheduledTime, 1000); assert.strictEqual(scheduledLastCtrl.cron, '* * * * 30'); } }, }; // inspect tests export const test = { async test(ctrl, env, ctx) { // Check URL with duplicate search param keys const url = new URL('http://user:pass@placeholder:8787/path?a=1&a=2&b=3'); assert.strictEqual( util.inspect(url), `URL { origin: 'http://placeholder:8787', href: 'http://user:pass@placeholder:8787/path?a=1&a=2&b=3', protocol: 'http:', username: 'user', password: 'pass', host: 'placeholder:8787', hostname: 'placeholder', port: '8787', pathname: '/path', search: '?a=1&a=2&b=3', hash: '', searchParams: URLSearchParams(3) { 'a' => '1', 'a' => '2', 'b' => '3' } }` ); // Check FormData with lower depth const formData = new FormData(); formData.set('string', 'hello'); formData.set( 'blob', new Blob(['

BLOB

'], { type: 'text/html', }) ); formData.set( 'file', new File(['password123'], 'passwords.txt', { type: 'text/plain', lastModified: 1000, }) ); assert.strictEqual( util.inspect(formData, { depth: 0 }), `FormData(3) { 'string' => 'hello', 'blob' => [File], 'file' => [File] }` ); // Check request with mutable headers const request = new Request('http://placeholder', { method: 'POST', body: 'message', headers: { 'Content-Type': 'text/plain' }, }); if (env.CACHE_ENABLED) { assert.strictEqual( util.inspect(request), `Request { method: 'POST', url: 'http://placeholder', headers: Headers(1) { 'content-type' => 'text/plain', [immutable]: false }, redirect: 'follow', fetcher: null, signal: AbortSignal { aborted: false, reason: undefined, onabort: null }, cf: undefined, integrity: '', keepalive: false, cache: undefined, body: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: 7n }, bodyUsed: false }` ); } else { assert.strictEqual( util.inspect(request), `Request { method: 'POST', url: 'http://placeholder', headers: Headers(1) { 'content-type' => 'text/plain', [immutable]: false }, redirect: 'follow', fetcher: null, signal: AbortSignal { aborted: false, reason: undefined, onabort: null }, cf: undefined, integrity: '', keepalive: false, body: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: 7n }, bodyUsed: false }` ); } // Check response with immutable headers const response = await env.SERVICE.fetch('http://placeholder/not-found'); assert.strictEqual( util.inspect(response), `Response { status: 404, statusText: 'Not Found', headers: Headers(0) { [immutable]: true }, ok: false, redirected: false, url: 'http://placeholder/not-found', webSocket: null, cf: undefined, type: 'default', body: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: 0n }, bodyUsed: false }` ); // Check `MessageEvent` with unimplemented properties const webSocketResponse = await env.SERVICE.fetch( 'http://placeholder/web-socket', { headers: { Upgrade: 'websocket' }, } ); const webSocket = webSocketResponse.webSocket; assert.notStrictEqual(webSocket, null); // The server-side WebSocketPair socket's binaryType depends on the compat flag. const bt = new WebSocketPair()[0].binaryType; const wsStr = `WebSocket {\n readyState: 1,\n url: null,\n protocol: '',\n extensions: '',\n binaryType: '${bt}'\n }`; const messagePromise = new Promise((resolve) => { webSocket.addEventListener('message', (event) => { assert.strictEqual( event.data, `MessageEvent { ports: [ [length]: 0 ], source: null, lastEventId: '', origin: null, data: 'data', type: 'message', eventPhase: 2, composed: false, bubbles: false, cancelable: false, defaultPrevented: false, returnValue: true, currentTarget: ${wsStr}, target: ${wsStr}, srcElement: ${wsStr}, timeStamp: 0, isTrusted: true, cancelBubble: false, NONE: 0, CAPTURING_PHASE: 1, AT_TARGET: 2, BUBBLING_PHASE: 3 }` ); resolve(); }); }); webSocket.accept(); webSocket.send('data'); webSocket.close(); await messagePromise; // Test sending to oversized URL (bigger than MAX_TRACE_BYTES), relevant primarily for tail worker test. await env.SERVICE.fetch('http://placeholder/' + '0'.repeat(2 ** 18)); }, }; async function assertRequestCacheThrowsError( cacheHeader, errorName = 'Error', errorMessage = "The 'cache' field on 'RequestInitializerDict' is not implemented." ) { assert.throws( () => { new Request('https://example.org', { cache: cacheHeader }); }, { name: errorName, message: errorMessage, } ); } async function assertFetchCacheRejectsError( cacheHeader, errorName = 'Error', errorMessage = "The 'cache' field on 'RequestInitializerDict' is not implemented." ) { await assert.rejects( (async () => { await fetch('https://example.org', { cache: cacheHeader }); })(), { name: errorName, message: errorMessage, } ); } export const cacheMode = { async test(ctrl, env, ctx) { var failureCases = [ 'default', 'force-cache', 'no-cache', 'only-if-cached', 'reload', 'unsupported', ]; assert.strictEqual('cache' in Request.prototype, env.CACHE_ENABLED); { const req = new Request('https://example.org', {}); assert.strictEqual(req.cache, undefined); } if (!env.CACHE_ENABLED) { failureCases.push('no-store'); for (const cacheMode in failureCases) { await assertRequestCacheThrowsError(cacheMode); await assertFetchCacheRejectsError(cacheMode); } } else { { const req = new Request('https://example.org', { cache: 'no-store' }); assert.strictEqual(req.cache, 'no-store'); } { const response = await env.SERVICE.fetch( 'http://placeholder/not-found', { cache: 'no-store' } ); assert.strictEqual( util.inspect(response), `Response { status: 404, statusText: 'Not Found', headers: Headers(0) { [immutable]: true }, ok: false, redirected: false, url: 'http://placeholder/not-found', webSocket: null, cf: undefined, type: 'default', body: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: 0n }, bodyUsed: false }` ); } for (const cacheMode in failureCases) { await assertRequestCacheThrowsError( cacheMode, 'TypeError', 'Unsupported cache mode: ' + cacheMode ); await assertFetchCacheRejectsError( cacheMode, 'TypeError', 'Unsupported cache mode: ' + cacheMode ); } // Test cache mode compatibility with cf.cacheTtl // cache: 'no-store' with cf: { cacheTtl: -1 } should succeed because // -1 is the NOCACHE_TTL value that no-store sets automatically. { const req = new Request('https://example.org', { cache: 'no-store', cf: { cacheTtl: -1 }, }); assert.strictEqual(req.cache, 'no-store'); // Fetch should succeed with compatible cacheTtl await env.SERVICE.fetch('http://placeholder/not-found', { cache: 'no-store', cf: { cacheTtl: -1 }, }); } // cache: 'no-store' with cf: { cacheTtl: 300 } should throw TypeError // because 300 is incompatible with no-store (validation happens at fetch time) { const req = new Request('https://example.org', { cache: 'no-store', cf: { cacheTtl: 300 }, }); // Request construction succeeds assert.strictEqual(req.cache, 'no-store'); // But fetch should fail with incompatible cacheTtl await assert.rejects( env.SERVICE.fetch('http://placeholder/not-found', { cache: 'no-store', cf: { cacheTtl: 300 }, }), { name: 'TypeError', message: /is not compatible with cache/, } ); } // cache: 'no-store' with cf: { cacheTtl: 0 } should also throw TypeError { await assert.rejects( env.SERVICE.fetch('http://placeholder/not-found', { cache: 'no-store', cf: { cacheTtl: 0 }, }), { name: 'TypeError', message: /is not compatible with cache/, } ); } } }, };