Skip to content
File

Blob: src/workerd/api/tests/opennextjs/opennext-ssr-test.js

javascript533 lines
1// Copyright (c) 2025 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { strictEqual, ok, deepStrictEqual } from 'node:assert';
6import OpenNextWorker from 'opennext-ssr-worker';
7 
8const mockAssets = {
9 async fetch(request) {
10 const url = new URL(request.url);
11 if (url.pathname.startsWith('/_next/static/')) {
12 return new Response('/* mock js */', {
13 status: 200,
14 headers: { 'content-type': 'application/javascript' },
15 });
16 }
17 return new Response('Not Found', { status: 404 });
18 },
19};
20 
21const mockCtx = {
22 waitUntil() {},
23 passThroughOnException() {},
24};
25 
26async function fetchWorker(path, options = {}) {
27 const request = new Request(`http://localhost${path}`, {
28 method: options.method || 'GET',
29 headers: { accept: 'text/html', ...options.headers },
30 body: options.body,
31 });
32 return OpenNextWorker.fetch(request, { ASSETS: mockAssets }, mockCtx);
33}
34 
35export const workerInitialization = {
36 async test() {
37 ok(OpenNextWorker, 'OpenNext worker should be loaded');
38 ok(
39 typeof OpenNextWorker.fetch === 'function',
40 'Worker should have fetch handler'
41 );
42 },
43};
44 
45export const apiRouteGET = {
46 async test() {
47 const response = await fetchWorker('/api/data?foo=bar&baz=123', {
48 headers: { accept: 'application/json' },
49 });
50 
51 strictEqual(response.status, 200);
52 strictEqual(response.headers.get('content-type'), 'application/json');
53 
54 const data = await response.json();
55 ok(typeof data.timestamp === 'number', 'Should include timestamp');
56 strictEqual(data.message, 'API response');
57 strictEqual(data.method, 'GET');
58 ok(data.searchParams, 'Should include search params');
59 },
60};
61 
62export const apiRoutePOST = {
63 async test() {
64 const requestBody = { key: 'value', nested: { a: 1, b: [1, 2, 3] } };
65 const response = await fetchWorker('/api/data', {
66 method: 'POST',
67 headers: {
68 'content-type': 'application/json',
69 accept: 'application/json',
70 },
71 body: JSON.stringify(requestBody),
72 });
73 
74 strictEqual(response.status, 200);
75 const data = await response.json();
76 deepStrictEqual(
77 data.received,
78 requestBody,
79 'Should echo back request body'
80 );
81 strictEqual(data.method, 'POST');
82 },
83};
84 
85export const apiRouteOPTIONS = {
86 async test() {
87 const response = await fetchWorker('/api/data', {
88 method: 'OPTIONS',
89 headers: {
90 origin: 'http://example.com',
91 'access-control-request-method': 'POST',
92 },
93 });
94 
95 strictEqual(response.status, 204, 'OPTIONS should return 204');
96 ok(
97 response.headers.get('access-control-allow-methods'),
98 'Should have CORS headers'
99 );
100 },
101};
102 
103export const cookiesAPIGet = {
104 async test() {
105 const response = await fetchWorker('/api/cookies', {
106 headers: {
107 accept: 'application/json',
108 cookie: 'test-cookie=hello; another=world',
109 },
110 });
111 
112 strictEqual(response.status, 200);
113 const data = await response.json();
114 ok(data.cookies, 'Should return cookies object');
115 },
116};
117 
118export const cookiesAPISet = {
119 async test() {
120 const response = await fetchWorker('/api/cookies', {
121 method: 'POST',
122 headers: {
123 'content-type': 'application/json',
124 accept: 'application/json',
125 },
126 body: JSON.stringify({
127 name: 'session',
128 value: 'abc123',
129 options: { httpOnly: true, path: '/' },
130 }),
131 });
132 
133 strictEqual(response.status, 200);
134 const setCookie = response.headers.get('set-cookie');
135 ok(setCookie, 'Should have Set-Cookie header');
136 ok(setCookie.includes('session=abc123'), 'Should set the cookie value');
137 },
138};
139 
140export const cookiesAPIDelete = {
141 async test() {
142 const response = await fetchWorker('/api/cookies?name=session', {
143 method: 'DELETE',
144 headers: { accept: 'application/json' },
145 });
146 
147 strictEqual(response.status, 200);
148 const data = await response.json();
149 strictEqual(data.deleted, 'session');
150 },
151};
152 
153export const indexPageSSR = {
154 async test() {
155 const response = await fetchWorker('/');
156 
157 const contentType = response.headers.get('content-type');
158 ok(
159 contentType?.includes('text/html'),
160 `Should be HTML, got: ${contentType}`
161 );
162 
163 const html = await response.text();
164 ok(
165 html.includes('<!DOCTYPE html>') || html.includes('<!doctype html>'),
166 'Should be HTML document'
167 );
168 ok(html.includes('<html'), 'Should have html tag');
169 ok(html.includes('SSR Test Page'), 'Should have page title');
170 },
171};
172 
173export const indexPageWithCookie = {
174 async test() {
175 const response = await fetchWorker('/', {
176 headers: { accept: 'text/html', cookie: 'test-cookie=from-request' },
177 });
178 
179 strictEqual(response.status, 200);
180 const html = await response.text();
181 ok(html.includes('Cookie value:'), 'Should show cookie section');
182 },
183};
184 
185export const dynamicRouteBasic = {
186 async test() {
187 const response = await fetchWorker('/posts/123');
188 
189 strictEqual(response.status, 200);
190 const html = await response.text();
191 ok(
192 html.includes('Post 123') || html.includes('123'),
193 'Should render post ID'
194 );
195 },
196};
197 
198export const dynamicRouteWithSpecialChars = {
199 async test() {
200 const response = await fetchWorker('/posts/hello-world-456');
201 
202 strictEqual(response.status, 200);
203 const html = await response.text();
204 ok(html.includes('hello-world-456'), 'Should handle slug with hyphens');
205 },
206};
207 
208export const dynamicRouteNumeric = {
209 async test() {
210 const response = await fetchWorker('/posts/999');
211 
212 strictEqual(response.status, 200);
213 const html = await response.text();
214 ok(html.includes('999'), 'Should handle numeric ID');
215 },
216};
217 
218export const streamingPageRenders = {
219 async test() {
220 const response = await fetchWorker('/streaming');
221 
222 strictEqual(response.status, 200);
223 ok(
224 response.headers.get('content-type')?.includes('text/html'),
225 'Should be HTML'
226 );
227 
228 const html = await response.text();
229 ok(html.includes('Streaming Test Page'), 'Should have page content');
230 ok(html.includes('Content chunk'), 'Should have streamed content');
231 },
232};
233 
234export const streamingResponseIsReadable = {
235 async test() {
236 const response = await fetchWorker('/streaming');
237 
238 ok(response.body, 'Response should have a body');
239 ok(
240 typeof response.body.getReader === 'function',
241 'Body should be a ReadableStream'
242 );
243 
244 const reader = response.body.getReader();
245 const chunks = [];
246 const decoder = new TextDecoder();
247 let totalBytes = 0;
248 
249 while (true) {
250 const { done, value } = await reader.read();
251 if (done) break;
252 chunks.push(value);
253 totalBytes += value.byteLength;
254 }
255 
256 ok(chunks.length > 0, 'Should receive at least one chunk');
257 ok(totalBytes > 0, `Should receive bytes, got ${totalBytes}`);
258 
259 const fullContent = chunks
260 .map((c) => decoder.decode(c, { stream: true }))
261 .join('');
262 ok(
263 fullContent.includes('<!DOCTYPE html>') ||
264 fullContent.includes('<!doctype html>'),
265 'Streamed content should be valid HTML'
266 );
267 },
268};
269 
270export const streamingMultipleChunks = {
271 async test() {
272 const response = await fetchWorker('/streaming');
273 const reader = response.body.getReader();
274 
275 let chunkCount = 0;
276 let totalSize = 0;
277 
278 while (true) {
279 const { done, value } = await reader.read();
280 if (done) break;
281 chunkCount++;
282 totalSize += value.byteLength;
283 }
284 
285 ok(chunkCount >= 1, `Should have chunks, got ${chunkCount}`);
286 ok(
287 totalSize > 1000,
288 `Should have substantial content, got ${totalSize} bytes`
289 );
290 },
291};
292 
293export const streamingConcurrentReads = {
294 async test() {
295 const responses = await Promise.all([
296 fetchWorker('/streaming'),
297 fetchWorker('/streaming'),
298 fetchWorker('/streaming'),
299 ]);
300 
301 const results = await Promise.all(
302 responses.map(async (response) => {
303 const reader = response.body.getReader();
304 let size = 0;
305 while (true) {
306 const { done, value } = await reader.read();
307 if (done) break;
308 size += value.byteLength;
309 }
310 return size;
311 })
312 );
313 
314 for (let i = 0; i < results.length; i++) {
315 ok(results[i] > 0, `Stream ${i} should have content`);
316 }
317 },
318};
319 
320export const redirectWithTarget = {
321 async test() {
322 const response = await fetchWorker(
323 '/redirect-test?target=/posts/redirected',
324 {
325 redirect: 'manual',
326 }
327 );
328 
329 ok(
330 response.status === 307 ||
331 response.status === 308 ||
332 response.status === 302,
333 `Should redirect, got status ${response.status}`
334 );
335 
336 const location = response.headers.get('location');
337 ok(location, 'Should have Location header');
338 ok(location.includes('/posts/redirected'), 'Should redirect to target');
339 },
340};
341 
342export const redirectPageWithoutTarget = {
343 async test() {
344 const response = await fetchWorker('/redirect-test');
345 
346 strictEqual(response.status, 200, 'Should render page without redirect');
347 const html = await response.text();
348 ok(html.includes('Redirect Test'), 'Should show redirect test page');
349 },
350};
351 
352export const rscRequestBasic = {
353 async test() {
354 const response = await fetchWorker('/', {
355 headers: { accept: '*/*', RSC: '1' },
356 });
357 
358 ok(
359 response.status >= 200 && response.status < 500,
360 `RSC request should not error, got ${response.status}`
361 );
362 const body = await response.text();
363 ok(body.length > 0, 'RSC response should not be empty');
364 },
365};
366 
367export const rscPrefetchRequest = {
368 async test() {
369 const response = await fetchWorker('/posts/456', {
370 headers: { accept: '*/*', RSC: '1', 'Next-Router-Prefetch': '1' },
371 });
372 
373 ok(
374 response.status >= 200 && response.status < 500,
375 `RSC prefetch should work, got ${response.status}`
376 );
377 },
378};
379 
380export const notFoundPage = {
381 async test() {
382 const response = await fetchWorker('/nonexistent-page-xyz-123');
383 
384 ok(
385 response.status === 404 || response.status === 200,
386 `Should handle 404, got: ${response.status}`
387 );
388 },
389};
390 
391export const notFoundAPIRoute = {
392 async test() {
393 const response = await fetchWorker('/api/nonexistent', {
394 headers: { accept: 'application/json' },
395 });
396 
397 ok(
398 response.status === 404 || response.status >= 400,
399 `API 404 should return error status, got: ${response.status}`
400 );
401 },
402};
403 
404export const customHeadersForwarded = {
405 async test() {
406 const response = await fetchWorker('/api/data', {
407 headers: {
408 accept: 'application/json',
409 'x-custom-header': 'test-value',
410 'x-forwarded-for': '192.168.1.1',
411 'user-agent': 'workerd-test/1.0',
412 },
413 });
414 
415 strictEqual(response.status, 200);
416 const data = await response.json();
417 ok(data.headers, 'Should return headers');
418 strictEqual(data.headers['x-custom-header'], 'test-value');
419 },
420};
421 
422export const acceptLanguageHeader = {
423 async test() {
424 const response = await fetchWorker('/api/data', {
425 headers: {
426 accept: 'application/json',
427 'accept-language': 'en-US,en;q=0.9,es;q=0.8',
428 },
429 });
430 
431 strictEqual(response.status, 200);
432 const data = await response.json();
433 ok(data.headers['accept-language'], 'Should forward accept-language');
434 },
435};
436 
437export const headRequest = {
438 async test() {
439 const response = await fetchWorker('/api/data', {
440 method: 'HEAD',
441 headers: { accept: 'application/json' },
442 });
443 
444 ok(response.status >= 200 && response.status < 500, 'HEAD should work');
445 const body = await response.text();
446 ok(body.length >= 0, 'HEAD response body handled');
447 },
448};
449 
450export const concurrentMixedRequests = {
451 async test() {
452 const responses = await Promise.all([
453 fetchWorker('/'),
454 fetchWorker('/api/data', { headers: { accept: 'application/json' } }),
455 fetchWorker('/posts/1'),
456 fetchWorker('/posts/2'),
457 fetchWorker('/streaming'),
458 fetchWorker('/api/cookies', { headers: { accept: 'application/json' } }),
459 ]);
460 
461 for (let i = 0; i < responses.length; i++) {
462 ok(
463 responses[i].status >= 200 && responses[i].status < 500,
464 `Request ${i} should succeed`
465 );
466 }
467 },
468};
469 
470export const concurrentAPIRequests = {
471 async test() {
472 const responses = await Promise.all(
473 Array.from({ length: 10 }, (_, i) =>
474 fetchWorker(`/api/data?id=${i}`, {
475 headers: { accept: 'application/json' },
476 })
477 )
478 );
479 
480 const bodies = await Promise.all(responses.map((r) => r.json()));
481 for (let i = 0; i < bodies.length; i++) {
482 ok(bodies[i].timestamp, `Request ${i} should have timestamp`);
483 }
484 },
485};
486 
487export const gracefulErrorHandling = {
488 async test() {
489 const paths = ['/500', '/../../../etc/passwd', '/api/data?error=true'];
490 
491 for (const path of paths) {
492 const response = await fetchWorker(path);
493 ok(
494 typeof response.status === 'number',
495 `${path} should return a response`
496 );
497 ok(
498 response.status >= 200 && response.status < 600,
499 `${path} should have valid status`
500 );
501 }
502 },
503};
504 
505export const cacheControlHeaders = {
506 async test() {
507 const response = await fetchWorker('/');
508 const cacheControl = response.headers.get('cache-control');
509 ok(
510 cacheControl === null || typeof cacheControl === 'string',
511 'Cache-Control header should be readable'
512 );
513 },
514};
515 
516export const contentTypeHeaders = {
517 async test() {
518 const htmlResponse = await fetchWorker('/');
519 ok(
520 htmlResponse.headers.get('content-type')?.includes('text/html'),
521 'HTML page should have text/html'
522 );
523 
524 const jsonResponse = await fetchWorker('/api/data', {
525 headers: { accept: 'application/json' },
526 });
527 ok(
528 jsonResponse.headers.get('content-type')?.includes('application/json'),
529 'API should have application/json'
530 );
531 },
532};