@pyreon/server 0.11.4 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/package.json +16 -16
- package/src/client.ts +28 -28
- package/src/handler.ts +18 -18
- package/src/html.ts +12 -12
- package/src/index.ts +9 -9
- package/src/island.ts +12 -12
- package/src/ssg.ts +9 -9
- package/src/tests/client.test.ts +90 -90
- package/src/tests/server.test.ts +303 -303
package/src/tests/server.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ComponentFn, VNode } from
|
|
2
|
-
import { h } from
|
|
3
|
-
import { createHandler } from
|
|
1
|
+
import type { ComponentFn, VNode } from '@pyreon/core'
|
|
2
|
+
import { h } from '@pyreon/core'
|
|
3
|
+
import { createHandler } from '../handler'
|
|
4
4
|
import {
|
|
5
5
|
buildClientEntryTag,
|
|
6
6
|
buildScripts,
|
|
@@ -9,253 +9,253 @@ import {
|
|
|
9
9
|
DEFAULT_TEMPLATE,
|
|
10
10
|
processCompiledTemplate,
|
|
11
11
|
processTemplate,
|
|
12
|
-
} from
|
|
13
|
-
import { island } from
|
|
14
|
-
import type { Middleware } from
|
|
15
|
-
import { prerender } from
|
|
12
|
+
} from '../html'
|
|
13
|
+
import { island } from '../island'
|
|
14
|
+
import type { Middleware } from '../middleware'
|
|
15
|
+
import { prerender } from '../ssg'
|
|
16
16
|
|
|
17
17
|
// ─── HTML template ───────────────────────────────────────────────────────────
|
|
18
18
|
|
|
19
|
-
describe(
|
|
20
|
-
test(
|
|
19
|
+
describe('HTML template', () => {
|
|
20
|
+
test('processTemplate replaces all placeholders', () => {
|
|
21
21
|
const result = processTemplate(DEFAULT_TEMPLATE, {
|
|
22
|
-
head:
|
|
23
|
-
app:
|
|
22
|
+
head: '<title>Test</title>',
|
|
23
|
+
app: '<div>Hello</div>',
|
|
24
24
|
scripts: '<script type="module" src="/app.js"></script>',
|
|
25
25
|
})
|
|
26
|
-
expect(result).toContain(
|
|
27
|
-
expect(result).toContain(
|
|
26
|
+
expect(result).toContain('<title>Test</title>')
|
|
27
|
+
expect(result).toContain('<div>Hello</div>')
|
|
28
28
|
expect(result).toContain('src="/app.js"')
|
|
29
|
-
expect(result).not.toContain(
|
|
30
|
-
expect(result).not.toContain(
|
|
31
|
-
expect(result).not.toContain(
|
|
29
|
+
expect(result).not.toContain('<!--pyreon-head-->')
|
|
30
|
+
expect(result).not.toContain('<!--pyreon-app-->')
|
|
31
|
+
expect(result).not.toContain('<!--pyreon-scripts-->')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
-
test(
|
|
35
|
-
const scripts = buildScripts(
|
|
36
|
-
expect(scripts).toContain(
|
|
34
|
+
test('buildScripts emits loader data + client entry', () => {
|
|
35
|
+
const scripts = buildScripts('/entry.js', { users: [{ id: 1 }] })
|
|
36
|
+
expect(scripts).toContain('window.__PYREON_LOADER_DATA__=')
|
|
37
37
|
expect(scripts).toContain('"users"')
|
|
38
38
|
expect(scripts).toContain('src="/entry.js"')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
test(
|
|
42
|
-
const scripts = buildScripts(
|
|
43
|
-
expect(scripts).not.toContain(
|
|
44
|
-
expect(scripts).toContain(
|
|
41
|
+
test('buildScripts escapes </script> in JSON', () => {
|
|
42
|
+
const scripts = buildScripts('/entry.js', { html: '</script><script>alert(1)' })
|
|
43
|
+
expect(scripts).not.toContain('</script><script>')
|
|
44
|
+
expect(scripts).toContain('<\\/script>')
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test(
|
|
48
|
-
const scripts = buildScripts(
|
|
49
|
-
expect(scripts).not.toContain(
|
|
47
|
+
test('buildScripts omits inline data when no loaders', () => {
|
|
48
|
+
const scripts = buildScripts('/entry.js', {})
|
|
49
|
+
expect(scripts).not.toContain('__PYREON_LOADER_DATA__')
|
|
50
50
|
expect(scripts).toContain('src="/entry.js"')
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
test(
|
|
54
|
-
const scripts = buildScripts(
|
|
55
|
-
expect(scripts).not.toContain(
|
|
53
|
+
test('buildScripts with null loaderData only emits client entry', () => {
|
|
54
|
+
const scripts = buildScripts('/entry.js', null)
|
|
55
|
+
expect(scripts).not.toContain('__PYREON_LOADER_DATA__')
|
|
56
56
|
expect(scripts).toContain('src="/entry.js"')
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
test(
|
|
60
|
-
const tpl =
|
|
61
|
-
const result = processTemplate(tpl, { head:
|
|
62
|
-
expect(result).toBe(
|
|
59
|
+
test('processTemplate works with custom template string', () => {
|
|
60
|
+
const tpl = '<head><!--pyreon-head--></head><main><!--pyreon-app--></main><!--pyreon-scripts-->'
|
|
61
|
+
const result = processTemplate(tpl, { head: '<title>X</title>', app: 'APP', scripts: 'JS' })
|
|
62
|
+
expect(result).toBe('<head><title>X</title></head><main>APP</main>JS')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
test(
|
|
66
|
-
expect(DEFAULT_TEMPLATE).toContain(
|
|
67
|
-
expect(DEFAULT_TEMPLATE).toContain(
|
|
68
|
-
expect(DEFAULT_TEMPLATE).toContain(
|
|
65
|
+
test('DEFAULT_TEMPLATE contains all three placeholders', () => {
|
|
66
|
+
expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-head-->')
|
|
67
|
+
expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-app-->')
|
|
68
|
+
expect(DEFAULT_TEMPLATE).toContain('<!--pyreon-scripts-->')
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
// ─── SSR Handler ─────────────────────────────────────────────────────────────
|
|
73
73
|
|
|
74
|
-
describe(
|
|
75
|
-
const Home: ComponentFn = () => h(
|
|
76
|
-
const About: ComponentFn = () => h(
|
|
74
|
+
describe('createHandler', () => {
|
|
75
|
+
const Home: ComponentFn = () => h('h1', null, 'Home')
|
|
76
|
+
const About: ComponentFn = () => h('h1', null, 'About')
|
|
77
77
|
const routes = [
|
|
78
|
-
{ path:
|
|
79
|
-
{ path:
|
|
78
|
+
{ path: '/', component: Home },
|
|
79
|
+
{ path: '/about', component: About },
|
|
80
80
|
]
|
|
81
81
|
|
|
82
|
-
test(
|
|
82
|
+
test('renders home page', async () => {
|
|
83
83
|
const handler = createHandler({ App: Home, routes })
|
|
84
|
-
const res = await handler(new Request(
|
|
84
|
+
const res = await handler(new Request('http://localhost/'))
|
|
85
85
|
const html = await res.text()
|
|
86
86
|
expect(res.status).toBe(200)
|
|
87
|
-
expect(res.headers.get(
|
|
88
|
-
expect(html).toContain(
|
|
89
|
-
expect(html).toContain(
|
|
87
|
+
expect(res.headers.get('Content-Type')).toContain('text/html')
|
|
88
|
+
expect(html).toContain('<h1>Home</h1>')
|
|
89
|
+
expect(html).toContain('<!DOCTYPE html>')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test(
|
|
93
|
-
const App: ComponentFn = () => h(
|
|
92
|
+
test('renders about page with correct route', async () => {
|
|
93
|
+
const App: ComponentFn = () => h('main', null, 'app')
|
|
94
94
|
const handler = createHandler({ App, routes })
|
|
95
|
-
const res = await handler(new Request(
|
|
95
|
+
const res = await handler(new Request('http://localhost/about'))
|
|
96
96
|
expect(res.status).toBe(200)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
-
test(
|
|
99
|
+
test('uses custom template', async () => {
|
|
100
100
|
const template =
|
|
101
|
-
|
|
101
|
+
'<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>'
|
|
102
102
|
const handler = createHandler({ App: Home, routes, template })
|
|
103
|
-
const res = await handler(new Request(
|
|
103
|
+
const res = await handler(new Request('http://localhost/'))
|
|
104
104
|
const html = await res.text()
|
|
105
|
-
expect(html).toContain(
|
|
106
|
-
expect(html).toContain(
|
|
107
|
-
expect(html).not.toContain(
|
|
105
|
+
expect(html).toContain('<html>')
|
|
106
|
+
expect(html).toContain('<h1>Home</h1>')
|
|
107
|
+
expect(html).not.toContain('<!--pyreon-app-->')
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
test(
|
|
111
|
-
const handler = createHandler({ App: Home, routes, clientEntry:
|
|
112
|
-
const res = await handler(new Request(
|
|
110
|
+
test('includes client entry script', async () => {
|
|
111
|
+
const handler = createHandler({ App: Home, routes, clientEntry: '/dist/client.js' })
|
|
112
|
+
const res = await handler(new Request('http://localhost/'))
|
|
113
113
|
const html = await res.text()
|
|
114
114
|
expect(html).toContain('src="/dist/client.js"')
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
test(
|
|
118
|
-
const WithLoader: ComponentFn = () => h(
|
|
117
|
+
test('serializes loader data into HTML', async () => {
|
|
118
|
+
const WithLoader: ComponentFn = () => h('div', null, 'loaded')
|
|
119
119
|
const loaderRoutes = [
|
|
120
120
|
{
|
|
121
|
-
path:
|
|
121
|
+
path: '/',
|
|
122
122
|
component: WithLoader,
|
|
123
123
|
loader: async () => ({ items: [1, 2, 3] }),
|
|
124
124
|
},
|
|
125
125
|
]
|
|
126
126
|
const handler = createHandler({ App: WithLoader, routes: loaderRoutes })
|
|
127
|
-
const res = await handler(new Request(
|
|
127
|
+
const res = await handler(new Request('http://localhost/'))
|
|
128
128
|
const html = await res.text()
|
|
129
|
-
expect(html).toContain(
|
|
129
|
+
expect(html).toContain('__PYREON_LOADER_DATA__')
|
|
130
130
|
expect(html).toContain('"items"')
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
test(
|
|
133
|
+
test('returns 500 on render error', async () => {
|
|
134
134
|
const BrokenApp: ComponentFn = () => {
|
|
135
|
-
throw new Error(
|
|
135
|
+
throw new Error('boom')
|
|
136
136
|
}
|
|
137
137
|
const handler = createHandler({ App: BrokenApp, routes })
|
|
138
|
-
const res = await handler(new Request(
|
|
138
|
+
const res = await handler(new Request('http://localhost/'))
|
|
139
139
|
expect(res.status).toBe(500)
|
|
140
|
-
expect(await res.text()).toBe(
|
|
140
|
+
expect(await res.text()).toBe('Internal Server Error')
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
test(
|
|
143
|
+
test('handles URL with query string', async () => {
|
|
144
144
|
const handler = createHandler({ App: Home, routes })
|
|
145
|
-
const res = await handler(new Request(
|
|
145
|
+
const res = await handler(new Request('http://localhost/?foo=bar&baz=1'))
|
|
146
146
|
expect(res.status).toBe(200)
|
|
147
147
|
const html = await res.text()
|
|
148
|
-
expect(html).toContain(
|
|
148
|
+
expect(html).toContain('<h1>Home</h1>')
|
|
149
149
|
})
|
|
150
150
|
})
|
|
151
151
|
|
|
152
152
|
// ─── Stream mode ──────────────────────────────────────────────────────────────
|
|
153
153
|
|
|
154
|
-
describe(
|
|
155
|
-
const Home: ComponentFn = () => h(
|
|
156
|
-
const routes = [{ path:
|
|
154
|
+
describe('createHandler — stream mode', () => {
|
|
155
|
+
const Home: ComponentFn = () => h('h1', null, 'Streamed')
|
|
156
|
+
const routes = [{ path: '/', component: Home }]
|
|
157
157
|
|
|
158
|
-
test(
|
|
159
|
-
const handler = createHandler({ App: Home, routes, mode:
|
|
160
|
-
const res = await handler(new Request(
|
|
158
|
+
test('returns a streaming response', async () => {
|
|
159
|
+
const handler = createHandler({ App: Home, routes, mode: 'stream' })
|
|
160
|
+
const res = await handler(new Request('http://localhost/'))
|
|
161
161
|
expect(res.status).toBe(200)
|
|
162
|
-
expect(res.headers.get(
|
|
162
|
+
expect(res.headers.get('Content-Type')).toContain('text/html')
|
|
163
163
|
const html = await res.text()
|
|
164
|
-
expect(html).toContain(
|
|
164
|
+
expect(html).toContain('<h1>Streamed</h1>')
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
-
test(
|
|
168
|
-
const handler = createHandler({ App: Home, routes, mode:
|
|
169
|
-
const res = await handler(new Request(
|
|
167
|
+
test('stream mode uses default template placeholders', async () => {
|
|
168
|
+
const handler = createHandler({ App: Home, routes, mode: 'stream' })
|
|
169
|
+
const res = await handler(new Request('http://localhost/'))
|
|
170
170
|
const html = await res.text()
|
|
171
171
|
// Should contain the template shell
|
|
172
|
-
expect(html).toContain(
|
|
173
|
-
expect(html).toContain(
|
|
172
|
+
expect(html).toContain('<!DOCTYPE html>')
|
|
173
|
+
expect(html).toContain('</html>')
|
|
174
174
|
// Script should be present
|
|
175
175
|
expect(html).toContain('src="/src/entry-client.ts"')
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
-
test(
|
|
178
|
+
test('stream mode with custom template', async () => {
|
|
179
179
|
const template =
|
|
180
|
-
|
|
181
|
-
const handler = createHandler({ App: Home, routes, mode:
|
|
182
|
-
const res = await handler(new Request(
|
|
180
|
+
'<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>'
|
|
181
|
+
const handler = createHandler({ App: Home, routes, mode: 'stream', template })
|
|
182
|
+
const res = await handler(new Request('http://localhost/'))
|
|
183
183
|
const html = await res.text()
|
|
184
|
-
expect(html).toContain(
|
|
185
|
-
expect(html).toContain(
|
|
184
|
+
expect(html).toContain('<h1>Streamed</h1>')
|
|
185
|
+
expect(html).toContain('</body></html>')
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
-
test(
|
|
188
|
+
test('stream mode with custom client entry', async () => {
|
|
189
189
|
const handler = createHandler({
|
|
190
190
|
App: Home,
|
|
191
191
|
routes,
|
|
192
|
-
mode:
|
|
193
|
-
clientEntry:
|
|
192
|
+
mode: 'stream',
|
|
193
|
+
clientEntry: '/dist/app.js',
|
|
194
194
|
})
|
|
195
|
-
const res = await handler(new Request(
|
|
195
|
+
const res = await handler(new Request('http://localhost/'))
|
|
196
196
|
const html = await res.text()
|
|
197
197
|
expect(html).toContain('src="/dist/app.js"')
|
|
198
198
|
})
|
|
199
199
|
|
|
200
|
-
test(
|
|
201
|
-
const badTemplate =
|
|
200
|
+
test('stream mode template without <!--pyreon-app--> throws', () => {
|
|
201
|
+
const badTemplate = '<html><!--pyreon-head--><!--pyreon-scripts--></html>'
|
|
202
202
|
// Template validation happens at createHandler time (compile-time, not per-request)
|
|
203
203
|
expect(() =>
|
|
204
|
-
createHandler({ App: Home, routes, mode:
|
|
205
|
-
).toThrow(
|
|
204
|
+
createHandler({ App: Home, routes, mode: 'stream', template: badTemplate }),
|
|
205
|
+
).toThrow('Template must contain <!--pyreon-app-->')
|
|
206
206
|
})
|
|
207
207
|
|
|
208
|
-
test(
|
|
208
|
+
test('stream mode includes middleware-set headers', async () => {
|
|
209
209
|
const mw: Middleware = (ctx) => {
|
|
210
|
-
ctx.headers.set(
|
|
210
|
+
ctx.headers.set('X-Custom', 'test-value')
|
|
211
211
|
}
|
|
212
|
-
const handler = createHandler({ App: Home, routes, mode:
|
|
213
|
-
const res = await handler(new Request(
|
|
214
|
-
expect(res.headers.get(
|
|
212
|
+
const handler = createHandler({ App: Home, routes, mode: 'stream', middleware: [mw] })
|
|
213
|
+
const res = await handler(new Request('http://localhost/'))
|
|
214
|
+
expect(res.headers.get('X-Custom')).toBe('test-value')
|
|
215
215
|
})
|
|
216
216
|
|
|
217
|
-
test(
|
|
218
|
-
const mw: Middleware = () => new Response(
|
|
219
|
-
const handler = createHandler({ App: Home, routes, mode:
|
|
220
|
-
const res = await handler(new Request(
|
|
217
|
+
test('stream mode middleware can short-circuit', async () => {
|
|
218
|
+
const mw: Middleware = () => new Response('blocked', { status: 403 })
|
|
219
|
+
const handler = createHandler({ App: Home, routes, mode: 'stream', middleware: [mw] })
|
|
220
|
+
const res = await handler(new Request('http://localhost/'))
|
|
221
221
|
expect(res.status).toBe(403)
|
|
222
|
-
expect(await res.text()).toBe(
|
|
222
|
+
expect(await res.text()).toBe('blocked')
|
|
223
223
|
})
|
|
224
224
|
})
|
|
225
225
|
|
|
226
226
|
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
227
227
|
|
|
228
|
-
describe(
|
|
229
|
-
const App: ComponentFn = () => h(
|
|
230
|
-
const routes = [{ path:
|
|
228
|
+
describe('middleware', () => {
|
|
229
|
+
const App: ComponentFn = () => h('div', null, 'app')
|
|
230
|
+
const routes = [{ path: '/', component: App }]
|
|
231
231
|
|
|
232
|
-
test(
|
|
232
|
+
test('middleware can short-circuit with a Response', async () => {
|
|
233
233
|
const authMiddleware: Middleware = (ctx) => {
|
|
234
|
-
if (!ctx.req.headers.get(
|
|
235
|
-
return new Response(
|
|
234
|
+
if (!ctx.req.headers.get('Authorization')) {
|
|
235
|
+
return new Response('Unauthorized', { status: 401 })
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
const handler = createHandler({ App, routes, middleware: [authMiddleware] })
|
|
239
239
|
|
|
240
|
-
const noAuth = await handler(new Request(
|
|
240
|
+
const noAuth = await handler(new Request('http://localhost/'))
|
|
241
241
|
expect(noAuth.status).toBe(401)
|
|
242
242
|
|
|
243
243
|
const withAuth = await handler(
|
|
244
|
-
new Request(
|
|
244
|
+
new Request('http://localhost/', { headers: { Authorization: 'Bearer token' } }),
|
|
245
245
|
)
|
|
246
246
|
expect(withAuth.status).toBe(200)
|
|
247
247
|
})
|
|
248
248
|
|
|
249
|
-
test(
|
|
249
|
+
test('middleware can set custom headers', async () => {
|
|
250
250
|
const cacheMiddleware: Middleware = (ctx) => {
|
|
251
|
-
ctx.headers.set(
|
|
251
|
+
ctx.headers.set('Cache-Control', 'max-age=3600')
|
|
252
252
|
}
|
|
253
253
|
const handler = createHandler({ App, routes, middleware: [cacheMiddleware] })
|
|
254
|
-
const res = await handler(new Request(
|
|
255
|
-
expect(res.headers.get(
|
|
254
|
+
const res = await handler(new Request('http://localhost/'))
|
|
255
|
+
expect(res.headers.get('Cache-Control')).toBe('max-age=3600')
|
|
256
256
|
})
|
|
257
257
|
|
|
258
|
-
test(
|
|
258
|
+
test('middleware chain runs in order', async () => {
|
|
259
259
|
const order: number[] = []
|
|
260
260
|
const mw1: Middleware = () => {
|
|
261
261
|
order.push(1)
|
|
@@ -267,28 +267,28 @@ describe("middleware", () => {
|
|
|
267
267
|
order.push(3)
|
|
268
268
|
}
|
|
269
269
|
const handler = createHandler({ App, routes, middleware: [mw1, mw2, mw3] })
|
|
270
|
-
await handler(new Request(
|
|
270
|
+
await handler(new Request('http://localhost/'))
|
|
271
271
|
expect(order).toEqual([1, 2, 3])
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
274
|
|
|
275
275
|
// ─── Stream mode error handling (handler.ts lines 175-178) ──────────────────
|
|
276
276
|
|
|
277
|
-
describe(
|
|
278
|
-
test(
|
|
277
|
+
describe('createHandler — stream mode error in rendering', () => {
|
|
278
|
+
test('stream mode handles render error gracefully', async () => {
|
|
279
279
|
let callCount = 0
|
|
280
280
|
const BrokenApp: ComponentFn = () => {
|
|
281
281
|
callCount++
|
|
282
|
-
if (callCount > 0) throw new Error(
|
|
283
|
-
return h(
|
|
282
|
+
if (callCount > 0) throw new Error('render boom')
|
|
283
|
+
return h('div', null, 'ok')
|
|
284
284
|
}
|
|
285
|
-
const routes = [{ path:
|
|
286
|
-
const handler = createHandler({ App: BrokenApp, routes, mode:
|
|
285
|
+
const routes = [{ path: '/', component: BrokenApp }]
|
|
286
|
+
const handler = createHandler({ App: BrokenApp, routes, mode: 'stream' })
|
|
287
287
|
// The stream mode should catch errors and emit an error script
|
|
288
288
|
// Since renderToStream might throw synchronously, the handler might throw
|
|
289
289
|
// or return a response depending on when the error occurs
|
|
290
290
|
try {
|
|
291
|
-
const res = await handler(new Request(
|
|
291
|
+
const res = await handler(new Request('http://localhost/'))
|
|
292
292
|
const _html = await res.text()
|
|
293
293
|
// If it returns a response, check it's still a valid response
|
|
294
294
|
expect(res.status).toBeDefined()
|
|
@@ -300,9 +300,9 @@ describe("createHandler — stream mode error in rendering", () => {
|
|
|
300
300
|
|
|
301
301
|
// ─── Middleware type exports ─────────────────────────────────────────────────
|
|
302
302
|
|
|
303
|
-
describe(
|
|
304
|
-
test(
|
|
305
|
-
const mod = await import(
|
|
303
|
+
describe('middleware types', () => {
|
|
304
|
+
test('MiddlewareContext and Middleware types are importable', async () => {
|
|
305
|
+
const mod = await import('../middleware')
|
|
306
306
|
// Just verify the module can be imported — it's pure types
|
|
307
307
|
expect(mod).toBeDefined()
|
|
308
308
|
})
|
|
@@ -310,22 +310,22 @@ describe("middleware types", () => {
|
|
|
310
310
|
|
|
311
311
|
// ─── Islands ─────────────────────────────────────────────────────────────────
|
|
312
312
|
|
|
313
|
-
describe(
|
|
314
|
-
test(
|
|
315
|
-
const Counter = island(() => Promise.resolve({ default: () => h(
|
|
316
|
-
name:
|
|
313
|
+
describe('island', () => {
|
|
314
|
+
test('island() returns a function with island metadata', () => {
|
|
315
|
+
const Counter = island(() => Promise.resolve({ default: () => h('div', null, '0') }), {
|
|
316
|
+
name: 'Counter',
|
|
317
317
|
})
|
|
318
|
-
expect(typeof Counter).toBe(
|
|
318
|
+
expect(typeof Counter).toBe('function')
|
|
319
319
|
expect((Counter as unknown as { __island: boolean }).__island).toBe(true)
|
|
320
|
-
expect(Counter.name).toBe(
|
|
320
|
+
expect(Counter.name).toBe('Counter')
|
|
321
321
|
})
|
|
322
322
|
|
|
323
|
-
test(
|
|
323
|
+
test('island() renders with <pyreon-island> wrapper during SSR', async () => {
|
|
324
324
|
const Inner: ComponentFn = (props) =>
|
|
325
|
-
h(
|
|
325
|
+
h('button', null, `Count: ${(props as Record<string, unknown>).initial}`)
|
|
326
326
|
const Counter = island<{ initial: number }>(() => Promise.resolve({ default: Inner }), {
|
|
327
|
-
name:
|
|
328
|
-
hydrate:
|
|
327
|
+
name: 'Counter',
|
|
328
|
+
hydrate: 'idle',
|
|
329
329
|
})
|
|
330
330
|
|
|
331
331
|
// Simulate SSR by calling the async component
|
|
@@ -334,104 +334,104 @@ describe("island", () => {
|
|
|
334
334
|
})
|
|
335
335
|
expect(vnode).not.toBeNull()
|
|
336
336
|
// The wrapper should be a pyreon-island element
|
|
337
|
-
expect(vnode.type).toBe(
|
|
338
|
-
expect(vnode.props[
|
|
339
|
-
expect(vnode.props[
|
|
340
|
-
const parsedProps = JSON.parse(vnode.props[
|
|
337
|
+
expect(vnode.type).toBe('pyreon-island')
|
|
338
|
+
expect(vnode.props['data-component']).toBe('Counter')
|
|
339
|
+
expect(vnode.props['data-hydrate']).toBe('idle')
|
|
340
|
+
const parsedProps = JSON.parse(vnode.props['data-props'] as string)
|
|
341
341
|
expect(parsedProps.initial).toBe(5)
|
|
342
342
|
})
|
|
343
343
|
|
|
344
|
-
test(
|
|
345
|
-
const Inner: ComponentFn = () => h(
|
|
346
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
344
|
+
test('island() strips non-serializable props', async () => {
|
|
345
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
346
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
|
|
347
347
|
|
|
348
348
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
349
|
-
label:
|
|
349
|
+
label: 'hello',
|
|
350
350
|
onClick: () => {},
|
|
351
|
-
sym: Symbol(
|
|
351
|
+
sym: Symbol('test'),
|
|
352
352
|
nested: { a: 1 },
|
|
353
353
|
})
|
|
354
|
-
const parsedProps = JSON.parse(vnode.props[
|
|
355
|
-
expect(parsedProps.label).toBe(
|
|
354
|
+
const parsedProps = JSON.parse(vnode.props['data-props'] as string)
|
|
355
|
+
expect(parsedProps.label).toBe('hello')
|
|
356
356
|
expect(parsedProps.onClick).toBeUndefined()
|
|
357
357
|
expect(parsedProps.sym).toBeUndefined()
|
|
358
358
|
expect(parsedProps.nested).toEqual({ a: 1 })
|
|
359
359
|
})
|
|
360
360
|
|
|
361
|
-
test(
|
|
362
|
-
const Inner: ComponentFn = () => h(
|
|
363
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
361
|
+
test('island() strips children prop from serialized props', async () => {
|
|
362
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
363
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
|
|
364
364
|
|
|
365
365
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
366
|
-
title:
|
|
367
|
-
children: h(
|
|
366
|
+
title: 'test',
|
|
367
|
+
children: h('span', null, 'child'),
|
|
368
368
|
})
|
|
369
|
-
const parsedProps = JSON.parse(vnode.props[
|
|
370
|
-
expect(parsedProps.title).toBe(
|
|
369
|
+
const parsedProps = JSON.parse(vnode.props['data-props'] as string)
|
|
370
|
+
expect(parsedProps.title).toBe('test')
|
|
371
371
|
expect(parsedProps.children).toBeUndefined()
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
-
test(
|
|
375
|
-
const Inner: ComponentFn = () => h(
|
|
376
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
374
|
+
test('island() strips undefined values from serialized props', async () => {
|
|
375
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
376
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Widget' })
|
|
377
377
|
|
|
378
378
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
379
|
-
present:
|
|
379
|
+
present: 'yes',
|
|
380
380
|
missing: undefined,
|
|
381
381
|
})
|
|
382
|
-
const parsedProps = JSON.parse(vnode.props[
|
|
383
|
-
expect(parsedProps.present).toBe(
|
|
384
|
-
expect(
|
|
382
|
+
const parsedProps = JSON.parse(vnode.props['data-props'] as string)
|
|
383
|
+
expect(parsedProps.present).toBe('yes')
|
|
384
|
+
expect('missing' in parsedProps).toBe(false)
|
|
385
385
|
})
|
|
386
386
|
|
|
387
|
-
test(
|
|
388
|
-
const Inner: ComponentFn = () => h(
|
|
389
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
387
|
+
test('island() resolves direct function module (not { default })', async () => {
|
|
388
|
+
const Inner: ComponentFn = () => h('span', null, 'direct')
|
|
389
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Direct' })
|
|
390
390
|
|
|
391
391
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
392
392
|
{},
|
|
393
393
|
)
|
|
394
|
-
expect(vnode.type).toBe(
|
|
395
|
-
expect(vnode.props[
|
|
394
|
+
expect(vnode.type).toBe('pyreon-island')
|
|
395
|
+
expect(vnode.props['data-component']).toBe('Direct')
|
|
396
396
|
})
|
|
397
397
|
|
|
398
398
|
test("island() defaults hydrate to 'load'", () => {
|
|
399
|
-
const Inner: ComponentFn = () => h(
|
|
400
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
401
|
-
expect((Widget as unknown as { hydrate: string }).hydrate).toBe(
|
|
399
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
400
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'NoHydrate' })
|
|
401
|
+
expect((Widget as unknown as { hydrate: string }).hydrate).toBe('load')
|
|
402
402
|
})
|
|
403
403
|
|
|
404
|
-
test(
|
|
405
|
-
const Inner: ComponentFn = () => h(
|
|
404
|
+
test('island() metadata properties are non-writable', () => {
|
|
405
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
406
406
|
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
407
|
-
name:
|
|
408
|
-
hydrate:
|
|
407
|
+
name: 'Frozen',
|
|
408
|
+
hydrate: 'visible',
|
|
409
409
|
})
|
|
410
410
|
const meta = Widget as unknown as { __island: boolean; hydrate: string }
|
|
411
411
|
expect(meta.__island).toBe(true)
|
|
412
|
-
expect(meta.hydrate).toBe(
|
|
412
|
+
expect(meta.hydrate).toBe('visible')
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
-
test(
|
|
416
|
-
const Inner: ComponentFn = () => h(
|
|
417
|
-
const Widget = island(() => Promise.resolve({ default: Inner }), { name:
|
|
415
|
+
test('island() serializes empty props as empty object', async () => {
|
|
416
|
+
const Inner: ComponentFn = () => h('div', null)
|
|
417
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: 'Empty' })
|
|
418
418
|
|
|
419
419
|
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
420
420
|
{},
|
|
421
421
|
)
|
|
422
|
-
expect(vnode.props[
|
|
422
|
+
expect(vnode.props['data-props']).toBe('{}')
|
|
423
423
|
})
|
|
424
424
|
})
|
|
425
425
|
|
|
426
426
|
// ─── SSG ─────────────────────────────────────────────────────────────────────
|
|
427
427
|
|
|
428
|
-
describe(
|
|
429
|
-
test(
|
|
430
|
-
const Home: ComponentFn = () => h(
|
|
431
|
-
const About: ComponentFn = () => h(
|
|
428
|
+
describe('prerender', () => {
|
|
429
|
+
test('generates HTML files for given paths', async () => {
|
|
430
|
+
const Home: ComponentFn = () => h('h1', null, 'Home')
|
|
431
|
+
const About: ComponentFn = () => h('h1', null, 'About')
|
|
432
432
|
const routes = [
|
|
433
|
-
{ path:
|
|
434
|
-
{ path:
|
|
433
|
+
{ path: '/', component: Home },
|
|
434
|
+
{ path: '/about', component: About },
|
|
435
435
|
]
|
|
436
436
|
const handler = createHandler({ App: Home, routes })
|
|
437
437
|
|
|
@@ -440,7 +440,7 @@ describe("prerender", () => {
|
|
|
440
440
|
|
|
441
441
|
const result = await prerender({
|
|
442
442
|
handler,
|
|
443
|
-
paths: [
|
|
443
|
+
paths: ['/', '/about'],
|
|
444
444
|
outDir: tmpDir,
|
|
445
445
|
})
|
|
446
446
|
|
|
@@ -449,11 +449,11 @@ describe("prerender", () => {
|
|
|
449
449
|
expect(result.elapsed).toBeGreaterThanOrEqual(0)
|
|
450
450
|
|
|
451
451
|
// Verify files exist
|
|
452
|
-
const { readFile, rm } = await import(
|
|
453
|
-
const indexHtml = await readFile(`${tmpDir}/index.html`,
|
|
454
|
-
expect(indexHtml).toContain(
|
|
452
|
+
const { readFile, rm } = await import('node:fs/promises')
|
|
453
|
+
const indexHtml = await readFile(`${tmpDir}/index.html`, 'utf-8')
|
|
454
|
+
expect(indexHtml).toContain('<h1>Home</h1>')
|
|
455
455
|
|
|
456
|
-
const aboutStat = await import(
|
|
456
|
+
const aboutStat = await import('node:fs').then((fs) =>
|
|
457
457
|
fs.existsSync(`${tmpDir}/about/index.html`),
|
|
458
458
|
)
|
|
459
459
|
expect(aboutStat).toBe(true)
|
|
@@ -462,114 +462,114 @@ describe("prerender", () => {
|
|
|
462
462
|
await rm(tmpDir, { recursive: true, force: true })
|
|
463
463
|
})
|
|
464
464
|
|
|
465
|
-
test(
|
|
466
|
-
const App: ComponentFn = () => h(
|
|
467
|
-
const handler = createHandler({ App, routes: [{ path:
|
|
465
|
+
test('onPage callback can skip pages', async () => {
|
|
466
|
+
const App: ComponentFn = () => h('div', null)
|
|
467
|
+
const handler = createHandler({ App, routes: [{ path: '/', component: App }] })
|
|
468
468
|
|
|
469
469
|
const tmpDir = `/tmp/pyreon-ssg-skip-${Date.now()}`
|
|
470
470
|
const result = await prerender({
|
|
471
471
|
handler,
|
|
472
|
-
paths: [
|
|
472
|
+
paths: ['/'],
|
|
473
473
|
outDir: tmpDir,
|
|
474
474
|
onPage: () => false, // skip all pages
|
|
475
475
|
})
|
|
476
476
|
|
|
477
477
|
expect(result.pages).toBe(0)
|
|
478
478
|
|
|
479
|
-
const { rm } = await import(
|
|
479
|
+
const { rm } = await import('node:fs/promises')
|
|
480
480
|
await rm(tmpDir, { recursive: true, force: true })
|
|
481
481
|
})
|
|
482
482
|
|
|
483
|
-
test(
|
|
484
|
-
const App: ComponentFn = () => h(
|
|
485
|
-
const handler = createHandler({ App, routes: [{ path:
|
|
483
|
+
test('paths can be an async function', async () => {
|
|
484
|
+
const App: ComponentFn = () => h('div', null)
|
|
485
|
+
const handler = createHandler({ App, routes: [{ path: '/', component: App }] })
|
|
486
486
|
|
|
487
487
|
const tmpDir = `/tmp/pyreon-ssg-async-${Date.now()}`
|
|
488
488
|
const result = await prerender({
|
|
489
489
|
handler,
|
|
490
|
-
paths: async () => [
|
|
490
|
+
paths: async () => ['/'],
|
|
491
491
|
outDir: tmpDir,
|
|
492
492
|
})
|
|
493
493
|
|
|
494
494
|
expect(result.pages).toBe(1)
|
|
495
495
|
|
|
496
|
-
const { rm } = await import(
|
|
496
|
+
const { rm } = await import('node:fs/promises')
|
|
497
497
|
await rm(tmpDir, { recursive: true, force: true })
|
|
498
498
|
})
|
|
499
499
|
|
|
500
|
-
test(
|
|
500
|
+
test('records errors for non-ok responses', async () => {
|
|
501
501
|
// Handler that returns 404 for /missing
|
|
502
502
|
const handler = async (req: Request) => {
|
|
503
503
|
const url = new URL(req.url)
|
|
504
|
-
if (url.pathname ===
|
|
505
|
-
return new Response(
|
|
504
|
+
if (url.pathname === '/missing') {
|
|
505
|
+
return new Response('Not Found', { status: 404 })
|
|
506
506
|
}
|
|
507
|
-
return new Response(
|
|
507
|
+
return new Response('<html>OK</html>', { status: 200 })
|
|
508
508
|
}
|
|
509
509
|
|
|
510
510
|
const tmpDir = `/tmp/pyreon-ssg-errors-${Date.now()}`
|
|
511
511
|
const result = await prerender({
|
|
512
512
|
handler,
|
|
513
|
-
paths: [
|
|
513
|
+
paths: ['/', '/missing'],
|
|
514
514
|
outDir: tmpDir,
|
|
515
515
|
})
|
|
516
516
|
|
|
517
517
|
expect(result.pages).toBe(1)
|
|
518
518
|
expect(result.errors).toHaveLength(1)
|
|
519
|
-
expect(result.errors[0]?.path).toBe(
|
|
519
|
+
expect(result.errors[0]?.path).toBe('/missing')
|
|
520
520
|
|
|
521
|
-
const { rm } = await import(
|
|
521
|
+
const { rm } = await import('node:fs/promises')
|
|
522
522
|
await rm(tmpDir, { recursive: true, force: true })
|
|
523
523
|
})
|
|
524
524
|
|
|
525
|
-
test(
|
|
525
|
+
test('records errors when handler throws', async () => {
|
|
526
526
|
const handler = async (_req: Request) => {
|
|
527
|
-
throw new Error(
|
|
527
|
+
throw new Error('handler exploded')
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
const tmpDir = `/tmp/pyreon-ssg-throw-${Date.now()}`
|
|
531
531
|
const result = await prerender({
|
|
532
532
|
handler,
|
|
533
|
-
paths: [
|
|
533
|
+
paths: ['/'],
|
|
534
534
|
outDir: tmpDir,
|
|
535
535
|
})
|
|
536
536
|
|
|
537
537
|
expect(result.pages).toBe(0)
|
|
538
538
|
expect(result.errors).toHaveLength(1)
|
|
539
|
-
expect(result.errors[0]?.path).toBe(
|
|
539
|
+
expect(result.errors[0]?.path).toBe('/')
|
|
540
540
|
expect(result.errors[0]?.error).toBeInstanceOf(Error)
|
|
541
541
|
|
|
542
|
-
const { rm } = await import(
|
|
542
|
+
const { rm } = await import('node:fs/promises')
|
|
543
543
|
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
544
544
|
})
|
|
545
545
|
|
|
546
|
-
test(
|
|
547
|
-
const handler = async (_req: Request) => new Response(
|
|
546
|
+
test('handles .html path suffix', async () => {
|
|
547
|
+
const handler = async (_req: Request) => new Response('<html>page</html>', { status: 200 })
|
|
548
548
|
|
|
549
549
|
const tmpDir = `/tmp/pyreon-ssg-html-${Date.now()}`
|
|
550
550
|
const result = await prerender({
|
|
551
551
|
handler,
|
|
552
|
-
paths: [
|
|
552
|
+
paths: ['/custom.html'],
|
|
553
553
|
outDir: tmpDir,
|
|
554
554
|
})
|
|
555
555
|
|
|
556
556
|
expect(result.pages).toBe(1)
|
|
557
557
|
|
|
558
|
-
const { readFile, rm } = await import(
|
|
559
|
-
const content = await readFile(`${tmpDir}/custom.html`,
|
|
560
|
-
expect(content).toBe(
|
|
558
|
+
const { readFile, rm } = await import('node:fs/promises')
|
|
559
|
+
const content = await readFile(`${tmpDir}/custom.html`, 'utf-8')
|
|
560
|
+
expect(content).toBe('<html>page</html>')
|
|
561
561
|
|
|
562
562
|
await rm(tmpDir, { recursive: true, force: true })
|
|
563
563
|
})
|
|
564
564
|
|
|
565
|
-
test(
|
|
566
|
-
const handler = async (_req: Request) => new Response(
|
|
565
|
+
test('onPage callback receives path and html', async () => {
|
|
566
|
+
const handler = async (_req: Request) => new Response('<html>content</html>', { status: 200 })
|
|
567
567
|
|
|
568
568
|
const received: { path: string; html: string }[] = []
|
|
569
569
|
const tmpDir = `/tmp/pyreon-ssg-onpage-${Date.now()}`
|
|
570
570
|
await prerender({
|
|
571
571
|
handler,
|
|
572
|
-
paths: [
|
|
572
|
+
paths: ['/', '/about'],
|
|
573
573
|
outDir: tmpDir,
|
|
574
574
|
onPage: (path, html) => {
|
|
575
575
|
received.push({ path, html })
|
|
@@ -577,53 +577,53 @@ describe("prerender", () => {
|
|
|
577
577
|
})
|
|
578
578
|
|
|
579
579
|
expect(received).toHaveLength(2)
|
|
580
|
-
expect(received.some((r) => r.path ===
|
|
581
|
-
expect(received.some((r) => r.path ===
|
|
582
|
-
expect(received[0]?.html).toBe(
|
|
580
|
+
expect(received.some((r) => r.path === '/')).toBe(true)
|
|
581
|
+
expect(received.some((r) => r.path === '/about')).toBe(true)
|
|
582
|
+
expect(received[0]?.html).toBe('<html>content</html>')
|
|
583
583
|
|
|
584
|
-
const { rm } = await import(
|
|
584
|
+
const { rm } = await import('node:fs/promises')
|
|
585
585
|
await rm(tmpDir, { recursive: true, force: true })
|
|
586
586
|
})
|
|
587
587
|
|
|
588
|
-
test(
|
|
589
|
-
let receivedUrl =
|
|
588
|
+
test('uses custom origin', async () => {
|
|
589
|
+
let receivedUrl = ''
|
|
590
590
|
const handler = async (req: Request) => {
|
|
591
591
|
receivedUrl = req.url
|
|
592
|
-
return new Response(
|
|
592
|
+
return new Response('<html></html>', { status: 200 })
|
|
593
593
|
}
|
|
594
594
|
|
|
595
595
|
const tmpDir = `/tmp/pyreon-ssg-origin-${Date.now()}`
|
|
596
596
|
await prerender({
|
|
597
597
|
handler,
|
|
598
|
-
paths: [
|
|
598
|
+
paths: ['/test'],
|
|
599
599
|
outDir: tmpDir,
|
|
600
|
-
origin:
|
|
600
|
+
origin: 'https://example.com',
|
|
601
601
|
})
|
|
602
602
|
|
|
603
|
-
expect(receivedUrl).toBe(
|
|
603
|
+
expect(receivedUrl).toBe('https://example.com/test')
|
|
604
604
|
|
|
605
|
-
const { rm } = await import(
|
|
605
|
+
const { rm } = await import('node:fs/promises')
|
|
606
606
|
await rm(tmpDir, { recursive: true, force: true })
|
|
607
607
|
})
|
|
608
608
|
|
|
609
|
-
test(
|
|
610
|
-
const handler = async (_req: Request) => new Response(
|
|
609
|
+
test('paths as sync function', async () => {
|
|
610
|
+
const handler = async (_req: Request) => new Response('<html></html>', { status: 200 })
|
|
611
611
|
|
|
612
612
|
const tmpDir = `/tmp/pyreon-ssg-sync-fn-${Date.now()}`
|
|
613
613
|
const result = await prerender({
|
|
614
614
|
handler,
|
|
615
|
-
paths: () => [
|
|
615
|
+
paths: () => ['/a', '/b'],
|
|
616
616
|
outDir: tmpDir,
|
|
617
617
|
})
|
|
618
618
|
|
|
619
619
|
expect(result.pages).toBe(2)
|
|
620
620
|
|
|
621
|
-
const { rm } = await import(
|
|
621
|
+
const { rm } = await import('node:fs/promises')
|
|
622
622
|
await rm(tmpDir, { recursive: true, force: true })
|
|
623
623
|
})
|
|
624
624
|
|
|
625
|
-
test(
|
|
626
|
-
const handler = async (_req: Request) => new Response(
|
|
625
|
+
test('batches more than 10 paths', async () => {
|
|
626
|
+
const handler = async (_req: Request) => new Response('<html>ok</html>', { status: 200 })
|
|
627
627
|
|
|
628
628
|
const paths = Array.from({ length: 15 }, (_, i) => `/page-${i}`)
|
|
629
629
|
const tmpDir = `/tmp/pyreon-ssg-batch-${Date.now()}`
|
|
@@ -636,53 +636,53 @@ describe("prerender", () => {
|
|
|
636
636
|
expect(result.pages).toBe(15)
|
|
637
637
|
expect(result.errors).toHaveLength(0)
|
|
638
638
|
|
|
639
|
-
const { rm } = await import(
|
|
639
|
+
const { rm } = await import('node:fs/promises')
|
|
640
640
|
await rm(tmpDir, { recursive: true, force: true })
|
|
641
641
|
})
|
|
642
642
|
})
|
|
643
643
|
|
|
644
644
|
// ─── compileTemplate ─────────────────────────────────────────────────────────
|
|
645
645
|
|
|
646
|
-
describe(
|
|
647
|
-
test(
|
|
646
|
+
describe('compileTemplate', () => {
|
|
647
|
+
test('splits template into 4 parts', () => {
|
|
648
648
|
const compiled = compileTemplate(DEFAULT_TEMPLATE)
|
|
649
649
|
expect(compiled.parts).toHaveLength(4)
|
|
650
650
|
})
|
|
651
651
|
|
|
652
|
-
test(
|
|
653
|
-
expect(() => compileTemplate(
|
|
654
|
-
|
|
652
|
+
test('throws when template is missing <!--pyreon-app-->', () => {
|
|
653
|
+
expect(() => compileTemplate('<html><!--pyreon-head--><!--pyreon-scripts--></html>')).toThrow(
|
|
654
|
+
'Template must contain <!--pyreon-app-->',
|
|
655
655
|
)
|
|
656
656
|
})
|
|
657
657
|
|
|
658
|
-
test(
|
|
658
|
+
test('handles template with all three placeholders in custom layout', () => {
|
|
659
659
|
const tpl =
|
|
660
|
-
|
|
660
|
+
'<head><!--pyreon-head--></head><main><!--pyreon-app--></main><footer><!--pyreon-scripts--></footer>'
|
|
661
661
|
const compiled = compileTemplate(tpl)
|
|
662
662
|
const result = processCompiledTemplate(compiled, {
|
|
663
|
-
head:
|
|
664
|
-
app:
|
|
665
|
-
scripts:
|
|
663
|
+
head: '<title>Hi</title>',
|
|
664
|
+
app: '<div>App</div>',
|
|
665
|
+
scripts: '<script></script>',
|
|
666
666
|
})
|
|
667
667
|
expect(result).toBe(
|
|
668
|
-
|
|
668
|
+
'<head><title>Hi</title></head><main><div>App</div></main><footer><script></script></footer>',
|
|
669
669
|
)
|
|
670
670
|
})
|
|
671
671
|
|
|
672
|
-
test(
|
|
673
|
-
const tpl =
|
|
672
|
+
test('handles template without <!--pyreon-scripts--> placeholder', () => {
|
|
673
|
+
const tpl = '<html><!--pyreon-head--><body><!--pyreon-app--></body></html>'
|
|
674
674
|
const compiled = compileTemplate(tpl)
|
|
675
|
-
expect(compiled.parts[3]).toBe(
|
|
675
|
+
expect(compiled.parts[3]).toBe('') // after-scripts is empty
|
|
676
676
|
})
|
|
677
677
|
})
|
|
678
678
|
|
|
679
679
|
// ─── processCompiledTemplate ─────────────────────────────────────────────────
|
|
680
680
|
|
|
681
|
-
describe(
|
|
682
|
-
test(
|
|
681
|
+
describe('processCompiledTemplate', () => {
|
|
682
|
+
test('produces same result as processTemplate', () => {
|
|
683
683
|
const data = {
|
|
684
|
-
head:
|
|
685
|
-
app:
|
|
684
|
+
head: '<title>Test</title>',
|
|
685
|
+
app: '<div>Hello</div>',
|
|
686
686
|
scripts: '<script type="module" src="/app.js"></script>',
|
|
687
687
|
}
|
|
688
688
|
const simple = processTemplate(DEFAULT_TEMPLATE, data)
|
|
@@ -691,109 +691,109 @@ describe("processCompiledTemplate", () => {
|
|
|
691
691
|
expect(fast).toBe(simple)
|
|
692
692
|
})
|
|
693
693
|
|
|
694
|
-
test(
|
|
694
|
+
test('works with empty data', () => {
|
|
695
695
|
const compiled = compileTemplate(DEFAULT_TEMPLATE)
|
|
696
|
-
const result = processCompiledTemplate(compiled, { head:
|
|
697
|
-
expect(result).not.toContain(
|
|
698
|
-
expect(result).not.toContain(
|
|
699
|
-
expect(result).not.toContain(
|
|
696
|
+
const result = processCompiledTemplate(compiled, { head: '', app: '', scripts: '' })
|
|
697
|
+
expect(result).not.toContain('<!--pyreon-head-->')
|
|
698
|
+
expect(result).not.toContain('<!--pyreon-app-->')
|
|
699
|
+
expect(result).not.toContain('<!--pyreon-scripts-->')
|
|
700
700
|
})
|
|
701
701
|
})
|
|
702
702
|
|
|
703
703
|
// ─── buildClientEntryTag ─────────────────────────────────────────────────────
|
|
704
704
|
|
|
705
|
-
describe(
|
|
706
|
-
test(
|
|
707
|
-
const tag = buildClientEntryTag(
|
|
705
|
+
describe('buildClientEntryTag', () => {
|
|
706
|
+
test('emits a module script tag with src', () => {
|
|
707
|
+
const tag = buildClientEntryTag('/dist/client.js')
|
|
708
708
|
expect(tag).toBe('<script type="module" src="/dist/client.js"></script>')
|
|
709
709
|
})
|
|
710
710
|
})
|
|
711
711
|
|
|
712
712
|
// ─── buildScriptsFast ────────────────────────────────────────────────────────
|
|
713
713
|
|
|
714
|
-
describe(
|
|
715
|
-
test(
|
|
716
|
-
const tag = buildClientEntryTag(
|
|
714
|
+
describe('buildScriptsFast', () => {
|
|
715
|
+
test('returns only client entry tag when no loader data', () => {
|
|
716
|
+
const tag = buildClientEntryTag('/app.js')
|
|
717
717
|
const result = buildScriptsFast(tag, null)
|
|
718
718
|
expect(result).toBe(tag)
|
|
719
719
|
})
|
|
720
720
|
|
|
721
|
-
test(
|
|
722
|
-
const tag = buildClientEntryTag(
|
|
721
|
+
test('returns only client entry tag when loader data is empty object', () => {
|
|
722
|
+
const tag = buildClientEntryTag('/app.js')
|
|
723
723
|
const result = buildScriptsFast(tag, {})
|
|
724
724
|
expect(result).toBe(tag)
|
|
725
725
|
})
|
|
726
726
|
|
|
727
|
-
test(
|
|
728
|
-
const tag = buildClientEntryTag(
|
|
727
|
+
test('includes inline loader data when present', () => {
|
|
728
|
+
const tag = buildClientEntryTag('/app.js')
|
|
729
729
|
const result = buildScriptsFast(tag, { users: [1, 2] })
|
|
730
|
-
expect(result).toContain(
|
|
730
|
+
expect(result).toContain('__PYREON_LOADER_DATA__')
|
|
731
731
|
expect(result).toContain('"users"')
|
|
732
732
|
expect(result).toContain(tag)
|
|
733
733
|
})
|
|
734
734
|
|
|
735
|
-
test(
|
|
736
|
-
const tag = buildClientEntryTag(
|
|
737
|
-
const result = buildScriptsFast(tag, { html:
|
|
738
|
-
expect(result).not.toContain(
|
|
739
|
-
expect(result).toContain(
|
|
735
|
+
test('escapes </script> in loader data JSON', () => {
|
|
736
|
+
const tag = buildClientEntryTag('/app.js')
|
|
737
|
+
const result = buildScriptsFast(tag, { html: '</script>' })
|
|
738
|
+
expect(result).not.toContain('</script><')
|
|
739
|
+
expect(result).toContain('<\\/script>')
|
|
740
740
|
})
|
|
741
741
|
})
|
|
742
742
|
|
|
743
743
|
// ─── Middleware chaining edge cases ──────────────────────────────────────────
|
|
744
744
|
|
|
745
|
-
describe(
|
|
746
|
-
const App: ComponentFn = () => h(
|
|
747
|
-
const routes = [{ path:
|
|
745
|
+
describe('middleware — edge cases', () => {
|
|
746
|
+
const App: ComponentFn = () => h('div', null, 'app')
|
|
747
|
+
const routes = [{ path: '/', component: App }]
|
|
748
748
|
|
|
749
|
-
test(
|
|
749
|
+
test('middleware can modify locals for downstream middleware', async () => {
|
|
750
750
|
const log: string[] = []
|
|
751
751
|
const mw1: Middleware = (ctx) => {
|
|
752
|
-
ctx.locals.user =
|
|
752
|
+
ctx.locals.user = 'alice'
|
|
753
753
|
}
|
|
754
754
|
const mw2: Middleware = (ctx) => {
|
|
755
755
|
log.push(`user=${ctx.locals.user}`)
|
|
756
756
|
}
|
|
757
757
|
const handler = createHandler({ App, routes, middleware: [mw1, mw2] })
|
|
758
|
-
await handler(new Request(
|
|
759
|
-
expect(log).toEqual([
|
|
758
|
+
await handler(new Request('http://localhost/'))
|
|
759
|
+
expect(log).toEqual(['user=alice'])
|
|
760
760
|
})
|
|
761
761
|
|
|
762
|
-
test(
|
|
762
|
+
test('early short-circuit prevents later middleware from running', async () => {
|
|
763
763
|
const log: number[] = []
|
|
764
764
|
const mw1: Middleware = () => {
|
|
765
765
|
log.push(1)
|
|
766
|
-
return new Response(
|
|
766
|
+
return new Response('blocked', { status: 403 })
|
|
767
767
|
}
|
|
768
768
|
const mw2: Middleware = () => {
|
|
769
769
|
log.push(2) // should never run
|
|
770
770
|
}
|
|
771
771
|
const handler = createHandler({ App, routes, middleware: [mw1, mw2] })
|
|
772
|
-
const res = await handler(new Request(
|
|
772
|
+
const res = await handler(new Request('http://localhost/'))
|
|
773
773
|
expect(res.status).toBe(403)
|
|
774
774
|
expect(log).toEqual([1]) // mw2 never ran
|
|
775
775
|
})
|
|
776
776
|
|
|
777
|
-
test(
|
|
777
|
+
test('async middleware is supported', async () => {
|
|
778
778
|
const mw: Middleware = async (ctx) => {
|
|
779
779
|
await new Promise((r) => setTimeout(r, 1))
|
|
780
|
-
ctx.headers.set(
|
|
780
|
+
ctx.headers.set('X-Async', 'true')
|
|
781
781
|
}
|
|
782
782
|
const handler = createHandler({ App, routes, middleware: [mw] })
|
|
783
|
-
const res = await handler(new Request(
|
|
784
|
-
expect(res.headers.get(
|
|
783
|
+
const res = await handler(new Request('http://localhost/'))
|
|
784
|
+
expect(res.headers.get('X-Async')).toBe('true')
|
|
785
785
|
})
|
|
786
786
|
|
|
787
|
-
test(
|
|
788
|
-
let receivedPath =
|
|
789
|
-
let receivedSearch =
|
|
787
|
+
test('middleware receives parsed URL and path', async () => {
|
|
788
|
+
let receivedPath = ''
|
|
789
|
+
let receivedSearch = ''
|
|
790
790
|
const mw: Middleware = (ctx) => {
|
|
791
791
|
receivedPath = ctx.path
|
|
792
792
|
receivedSearch = ctx.url.search
|
|
793
793
|
}
|
|
794
794
|
const handler = createHandler({ App, routes, middleware: [mw] })
|
|
795
|
-
await handler(new Request(
|
|
796
|
-
expect(receivedPath).toBe(
|
|
797
|
-
expect(receivedSearch).toBe(
|
|
795
|
+
await handler(new Request('http://localhost/about?foo=bar'))
|
|
796
|
+
expect(receivedPath).toBe('/about?foo=bar')
|
|
797
|
+
expect(receivedSearch).toBe('?foo=bar')
|
|
798
798
|
})
|
|
799
799
|
})
|