@atom.io/template-spa-astro-ssr 0.0.0-stage → 0.0.1
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/.reylo.yaml +1 -0
- package/LICENSE +373 -0
- package/README.md +228 -2
- package/README.mdx +89 -0
- package/_gitignore +6 -0
- package/astro.config.ts +17 -0
- package/exhibits/run.sh +10 -0
- package/exhibits/workspace.sh +2 -0
- package/package.json +60 -3
- package/schema.sql +5 -0
- package/scripts/seed.ts +14 -0
- package/src/Drafts.spa.tsx +62 -0
- package/src/api.ts +9 -0
- package/src/layouts/Page.astro +24 -0
- package/src/model.ts +21 -0
- package/src/pages/about.astro +25 -0
- package/src/pages/api/trpc/[...trpc].ts +13 -0
- package/src/pages/index.astro +28 -0
- package/src/prepare-drafts.ts +22 -0
- package/src/server/drafts.ts +17 -0
- package/src/server/read-draft.sql +1 -0
- package/src/server/router.ts +14 -0
- package/src/style.css +170 -0
- package/tests/browser.mjs +302 -0
- package/tests/types.ts +17 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { DatabaseSync } from "node:sqlite"
|
|
4
|
+
import { setTimeout } from "node:timers/promises"
|
|
5
|
+
import { dev, preview } from "astro"
|
|
6
|
+
import { chromium } from "playwright"
|
|
7
|
+
|
|
8
|
+
const root = resolve(import.meta.dirname, `..`)
|
|
9
|
+
process.chdir(root)
|
|
10
|
+
const development = process.argv.includes(`--dev`)
|
|
11
|
+
const server = await (development ? dev : preview)({
|
|
12
|
+
root,
|
|
13
|
+
server: { host: `127.0.0.1`, port: 0 },
|
|
14
|
+
logLevel: `error`,
|
|
15
|
+
})
|
|
16
|
+
const port = development ? server.address.port : server.server.address().port
|
|
17
|
+
const stop = () => server.stop()
|
|
18
|
+
const database = new DatabaseSync(`drafts.sqlite`)
|
|
19
|
+
const initialBody = `A note from SQLite for document 1.`
|
|
20
|
+
database.prepare(`UPDATE drafts SET body = ? WHERE id = 1`).run(initialBody)
|
|
21
|
+
let browser
|
|
22
|
+
try {
|
|
23
|
+
const origin = `http://127.0.0.1:${port}`
|
|
24
|
+
const response = await fetch(origin)
|
|
25
|
+
assert.equal(response.status, 200)
|
|
26
|
+
const html = await response.text()
|
|
27
|
+
const cachedAt = Date.now()
|
|
28
|
+
if (!development) {
|
|
29
|
+
assert.equal(response.headers.get(`x-astro-cache`), `MISS`)
|
|
30
|
+
}
|
|
31
|
+
assert.ok(html.includes(`A note from SQLite for document 1.`))
|
|
32
|
+
assert.ok(html.indexOf(`data-resumable`) < html.indexOf(`<astro-island`))
|
|
33
|
+
assert.equal((html.match(/data-resumable/g) ?? []).length, 1)
|
|
34
|
+
assert.equal((html.match(/aria-busy="true"/g) ?? []).length, 0)
|
|
35
|
+
const changedBody = `Changed after the first server render`
|
|
36
|
+
database.prepare(`UPDATE drafts SET body = ? WHERE id = 1`).run(changedBody)
|
|
37
|
+
const repeated = await fetch(origin)
|
|
38
|
+
const repeatedHtml = await repeated.text()
|
|
39
|
+
assert.equal(repeated.status, 200)
|
|
40
|
+
if (development) {
|
|
41
|
+
assert.ok(repeatedHtml.includes(changedBody))
|
|
42
|
+
assert.notEqual(repeatedHtml, html)
|
|
43
|
+
} else {
|
|
44
|
+
assert.equal(repeated.headers.get(`x-astro-cache`), `HIT`)
|
|
45
|
+
assert.equal(
|
|
46
|
+
repeatedHtml,
|
|
47
|
+
html,
|
|
48
|
+
`Cache hits preserve HTML and sidecar together`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
browser = await chromium.launch()
|
|
52
|
+
const page = await browser.newPage()
|
|
53
|
+
const errors = []
|
|
54
|
+
const scripts = []
|
|
55
|
+
const rpcRequests = []
|
|
56
|
+
const rpcResponses = []
|
|
57
|
+
page.on(`request`, (request) => {
|
|
58
|
+
if (request.url().includes(`/api/trpc/`)) rpcRequests.push(request.url())
|
|
59
|
+
})
|
|
60
|
+
page.on(`pageerror`, (error) => errors.push(error.message))
|
|
61
|
+
page.on(`console`, (message) => {
|
|
62
|
+
if (message.type() === `error`) errors.push(message.text())
|
|
63
|
+
})
|
|
64
|
+
page.on(`response`, (result) => {
|
|
65
|
+
if (result.url().includes(`/api/trpc/`))
|
|
66
|
+
rpcResponses.push(result.allHeaders())
|
|
67
|
+
if (result.request().resourceType() === `script`)
|
|
68
|
+
scripts.push(result.text().then((code) => ({ code, url: result.url() })))
|
|
69
|
+
})
|
|
70
|
+
// Hold JavaScript until we can inspect and retain the actual server DOM.
|
|
71
|
+
const gate = Promise.withResolvers()
|
|
72
|
+
await page.route(`**/*`, async (route) => {
|
|
73
|
+
if (route.request().resourceType() === `script`) await gate.promise
|
|
74
|
+
await route.continue()
|
|
75
|
+
})
|
|
76
|
+
await page.goto(origin, { waitUntil: `commit` })
|
|
77
|
+
await page.waitForSelector(`textarea`)
|
|
78
|
+
assert.equal(await page.locator(`textarea`).count(), 4)
|
|
79
|
+
assert.equal(
|
|
80
|
+
await page.locator(`textarea`).first().inputValue(),
|
|
81
|
+
development ? changedBody : initialBody,
|
|
82
|
+
)
|
|
83
|
+
const seeds = await page.locator(`[data-revision]`).allTextContents()
|
|
84
|
+
assert.equal(new Set(seeds).size, 2)
|
|
85
|
+
await page.evaluate(() => {
|
|
86
|
+
window.__draftNodes = [...document.querySelectorAll(`textarea`)]
|
|
87
|
+
window.__draftHost = document.querySelector(`astro-island`)
|
|
88
|
+
})
|
|
89
|
+
gate.resolve()
|
|
90
|
+
await page.waitForSelector(`astro-island[client="load"]:not([ssr])`)
|
|
91
|
+
assert.equal(
|
|
92
|
+
await page.locator(`astro-island[client="visible"][ssr]`).count(),
|
|
93
|
+
1,
|
|
94
|
+
)
|
|
95
|
+
// Edit before the delayed island activates. It must render live shared state.
|
|
96
|
+
const cards = page.locator(`.draft`)
|
|
97
|
+
await cards.nth(0).locator(`textarea`).fill(`A browser edit`)
|
|
98
|
+
await page
|
|
99
|
+
.locator(`astro-island[client="visible"] .draft`)
|
|
100
|
+
.first()
|
|
101
|
+
.scrollIntoViewIfNeeded()
|
|
102
|
+
await page.waitForSelector(`astro-island[client="visible"]:not([ssr])`)
|
|
103
|
+
assert.equal(
|
|
104
|
+
await page.evaluate(() =>
|
|
105
|
+
[...document.querySelectorAll(`textarea`)].every((element, index) =>
|
|
106
|
+
index < 2
|
|
107
|
+
? element === window.__draftNodes[index]
|
|
108
|
+
: element !== window.__draftNodes[index],
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
true,
|
|
112
|
+
)
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
await page.locator(`[data-revision]`).allTextContents(),
|
|
115
|
+
seeds,
|
|
116
|
+
)
|
|
117
|
+
assert.equal(
|
|
118
|
+
await page
|
|
119
|
+
.locator(`textarea`)
|
|
120
|
+
.evaluateAll(
|
|
121
|
+
(elements) => new Set(elements.map((element) => element.id)).size,
|
|
122
|
+
),
|
|
123
|
+
4,
|
|
124
|
+
)
|
|
125
|
+
await cards.nth(0).locator(`textarea`).fill(`A browser edit`)
|
|
126
|
+
await page.waitForFunction(
|
|
127
|
+
() =>
|
|
128
|
+
document.querySelector(`[data-preview]`).textContent === `A browser edit`,
|
|
129
|
+
)
|
|
130
|
+
assert.equal(
|
|
131
|
+
await cards.nth(2).locator(`textarea`).inputValue(),
|
|
132
|
+
`A browser edit`,
|
|
133
|
+
)
|
|
134
|
+
assert.equal(
|
|
135
|
+
rpcRequests.length,
|
|
136
|
+
0,
|
|
137
|
+
`Hydration must consume the sidecar without RPC`,
|
|
138
|
+
)
|
|
139
|
+
const refresh = async (index, body) => {
|
|
140
|
+
database.prepare(`UPDATE drafts SET body = ? WHERE id = 1`).run(body)
|
|
141
|
+
const before = await cards
|
|
142
|
+
.nth(index)
|
|
143
|
+
.locator(`[data-revision]`)
|
|
144
|
+
.textContent()
|
|
145
|
+
await cards.nth(index).locator(`button`).click()
|
|
146
|
+
await page.waitForFunction(
|
|
147
|
+
(previous) =>
|
|
148
|
+
document.querySelectorAll(`[data-revision]`)[previous.index]
|
|
149
|
+
.textContent !== previous.before,
|
|
150
|
+
{ index, before },
|
|
151
|
+
)
|
|
152
|
+
assert.equal(await cards.nth(index).locator(`textarea`).inputValue(), body)
|
|
153
|
+
}
|
|
154
|
+
await refresh(0, `Updated in SQLite after SSR`)
|
|
155
|
+
await refresh(0, `Updated in SQLite again`)
|
|
156
|
+
assert.equal(rpcRequests.length, 2)
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
(await page.locator(`[data-revision]`).allTextContents()).filter(
|
|
159
|
+
(_, index) => index % 2 === 1,
|
|
160
|
+
),
|
|
161
|
+
[seeds[1], seeds[3]],
|
|
162
|
+
)
|
|
163
|
+
await refresh(2, `Another database revision`)
|
|
164
|
+
assert.equal(rpcRequests.length, 3)
|
|
165
|
+
assert.equal(
|
|
166
|
+
await cards.nth(0).locator(`textarea`).inputValue(),
|
|
167
|
+
`Another database revision`,
|
|
168
|
+
)
|
|
169
|
+
if (process.env.TEMPLATE_SCREENSHOT)
|
|
170
|
+
await page.screenshot({
|
|
171
|
+
path: process.env.TEMPLATE_SCREENSHOT,
|
|
172
|
+
fullPage: true,
|
|
173
|
+
})
|
|
174
|
+
await cards.nth(0).locator(`textarea`).fill(`Keep this edit across navigation`)
|
|
175
|
+
database
|
|
176
|
+
.prepare(`UPDATE drafts SET body = ? WHERE id = 1`)
|
|
177
|
+
.run(`A newer server value`)
|
|
178
|
+
// Inspect incoming DOM before activation; new islands must mount fresh.
|
|
179
|
+
await page.evaluate(() => {
|
|
180
|
+
document.addEventListener(`astro:after-swap`, () => {
|
|
181
|
+
window.__incomingNodes = [...document.querySelectorAll(`textarea`)]
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
await page.getByRole(`link`, { name: `How it works` }).click()
|
|
185
|
+
await page.waitForURL(`**/about`)
|
|
186
|
+
await page.waitForFunction(() => window.__draftHost.childNodes.length === 0)
|
|
187
|
+
await page.getByRole(`link`, { name: `Back to drafts` }).click()
|
|
188
|
+
await page.waitForSelector(`astro-island[client="load"]:not([ssr])`)
|
|
189
|
+
assert.equal(
|
|
190
|
+
await page.locator(`astro-island[client="visible"][ssr]`).count(),
|
|
191
|
+
1,
|
|
192
|
+
)
|
|
193
|
+
await page
|
|
194
|
+
.locator(`astro-island[client="visible"] .draft`)
|
|
195
|
+
.first()
|
|
196
|
+
.scrollIntoViewIfNeeded()
|
|
197
|
+
await page.waitForSelector(`astro-island[client="visible"]:not([ssr])`)
|
|
198
|
+
assert.equal(
|
|
199
|
+
rpcRequests.length,
|
|
200
|
+
3,
|
|
201
|
+
`Navigation must retain already initialized values without RPC`,
|
|
202
|
+
)
|
|
203
|
+
assert.equal(
|
|
204
|
+
await cards.nth(0).locator(`textarea`).inputValue(),
|
|
205
|
+
`Keep this edit across navigation`,
|
|
206
|
+
)
|
|
207
|
+
assert.equal(
|
|
208
|
+
await cards.nth(2).locator(`textarea`).inputValue(),
|
|
209
|
+
`Keep this edit across navigation`,
|
|
210
|
+
)
|
|
211
|
+
assert.ok(
|
|
212
|
+
await page.evaluate(() =>
|
|
213
|
+
[...document.querySelectorAll(`textarea`)].every(
|
|
214
|
+
(node, index) => node !== window.__incomingNodes[index],
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
await refresh(0, `Refreshed after returning`)
|
|
220
|
+
assert.equal(rpcRequests.length, 4)
|
|
221
|
+
// A full load starts a new application; with no edits, both initial roots hydrate.
|
|
222
|
+
const freshGate = Promise.withResolvers()
|
|
223
|
+
await page.unroute(`**/*`)
|
|
224
|
+
await page.route(`**/*`, async (route) => {
|
|
225
|
+
if (route.request().resourceType() === `script`) await freshGate.promise
|
|
226
|
+
await route.continue()
|
|
227
|
+
})
|
|
228
|
+
const fullLoad = await page.goto(origin, { waitUntil: `commit` })
|
|
229
|
+
if (!development) assert.equal(fullLoad.headers()[`x-astro-cache`], `HIT`)
|
|
230
|
+
await page.waitForSelector(`textarea`)
|
|
231
|
+
await page.evaluate(() => {
|
|
232
|
+
window.__pristineNodes = [...document.querySelectorAll(`textarea`)]
|
|
233
|
+
})
|
|
234
|
+
freshGate.resolve()
|
|
235
|
+
await page.waitForSelector(`astro-island[client="load"]:not([ssr])`)
|
|
236
|
+
await page
|
|
237
|
+
.locator(`astro-island[client="visible"] .draft`)
|
|
238
|
+
.first()
|
|
239
|
+
.scrollIntoViewIfNeeded()
|
|
240
|
+
await page.waitForSelector(`astro-island[client="visible"]:not([ssr])`)
|
|
241
|
+
assert.equal(
|
|
242
|
+
await page.locator(`textarea`).first().inputValue(),
|
|
243
|
+
development ? `Refreshed after returning` : initialBody,
|
|
244
|
+
)
|
|
245
|
+
assert.ok(
|
|
246
|
+
await page.evaluate(() =>
|
|
247
|
+
[...document.querySelectorAll(`textarea`)].every(
|
|
248
|
+
(node, index) => node === window.__pristineNodes[index],
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
assert.equal(rpcRequests.length, 4)
|
|
253
|
+
|
|
254
|
+
// Even a new browser application resumed from the old cache can reset freshly.
|
|
255
|
+
await refresh(0, `Fresh reset from a cached snapshot`)
|
|
256
|
+
assert.equal(rpcRequests.length, 5)
|
|
257
|
+
const rpcHeaders = await Promise.all(rpcResponses)
|
|
258
|
+
assert.equal(rpcHeaders.length, 5)
|
|
259
|
+
for (const headers of rpcHeaders) {
|
|
260
|
+
assert.equal(headers[`cache-control`], `no-store`)
|
|
261
|
+
assert.equal(headers[`x-astro-cache`], undefined)
|
|
262
|
+
assert.equal(headers[`cdn-cache-control`], undefined)
|
|
263
|
+
}
|
|
264
|
+
if (!development) {
|
|
265
|
+
const stillCached = await fetch(origin)
|
|
266
|
+
assert.equal(stillCached.headers.get(`x-astro-cache`), `HIT`)
|
|
267
|
+
assert.equal(
|
|
268
|
+
await stillCached.text(),
|
|
269
|
+
html,
|
|
270
|
+
`RPC resets do not rewrite the SSR cache`,
|
|
271
|
+
)
|
|
272
|
+
// Exercise the real production TTL, without a test-only cache configuration.
|
|
273
|
+
await setTimeout(Math.max(0, cachedAt + 61_000 - Date.now()))
|
|
274
|
+
const expired = await fetch(origin)
|
|
275
|
+
assert.equal(expired.headers.get(`x-astro-cache`), `MISS`)
|
|
276
|
+
const freshHtml = await expired.text()
|
|
277
|
+
assert.ok(freshHtml.includes(`Fresh reset from a cached snapshot`))
|
|
278
|
+
assert.notEqual(freshHtml, html)
|
|
279
|
+
const recached = await fetch(origin)
|
|
280
|
+
assert.equal(recached.headers.get(`x-astro-cache`), `HIT`)
|
|
281
|
+
assert.equal(await recached.text(), freshHtml)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
assert.deepEqual(errors, [])
|
|
285
|
+
const executable = await Promise.all(scripts)
|
|
286
|
+
assert.ok(executable.length > 0)
|
|
287
|
+
for (const { code, url } of executable) {
|
|
288
|
+
assert.doesNotMatch(url, /server\/(drafts|router)|read-draft\.sql/)
|
|
289
|
+
assert.doesNotMatch(
|
|
290
|
+
code,
|
|
291
|
+
/node:sqlite|read-draft\.sql|SELECT id, title|A note from SQLite/,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
console.log(
|
|
295
|
+
`${development ? `Development` : `Production`}: ${development ? `uncached SSR` : `cached SSR and 60-second expiry`}, no-store RPC resets, server data, client:load and client:visible, initial DOM hydration, shared state, delayed fresh mounts, RPC resets, navigation without lost edits, and server-code removal passed.`,
|
|
296
|
+
)
|
|
297
|
+
} finally {
|
|
298
|
+
await browser?.close()
|
|
299
|
+
await stop()
|
|
300
|
+
database.prepare(`UPDATE drafts SET body = ? WHERE id = 1`).run(initialBody)
|
|
301
|
+
database.close()
|
|
302
|
+
}
|
package/tests/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { api, type Draft } from "../src/api.ts"
|
|
2
|
+
import { readDraft } from "../src/server/drafts.ts"
|
|
3
|
+
|
|
4
|
+
const direct = readDraft(1)
|
|
5
|
+
const revision: string = direct.revision
|
|
6
|
+
const dto: Draft = direct
|
|
7
|
+
const dtoRevision: string = dto.revision
|
|
8
|
+
const remote = await api.drafts.get.query({ id: 1 })
|
|
9
|
+
const remoteRevision: string = remote.revision
|
|
10
|
+
void [revision, dtoRevision, remoteRevision]
|
|
11
|
+
// @ts-expect-error IDs are numeric throughout the transport contract.
|
|
12
|
+
void api.drafts.get.query({ id: `one` })
|
|
13
|
+
|
|
14
|
+
// @ts-expect-error The inferred result does not invent columns absent from SQL.
|
|
15
|
+
void remote.missingColumn
|
|
16
|
+
// @ts-expect-error The shared server function also requires numeric IDs.
|
|
17
|
+
readDraft(`one`)
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "astro/tsconfigs/strict",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": [
|
|
6
|
+
"DOM",
|
|
7
|
+
"ESNext"
|
|
8
|
+
],
|
|
9
|
+
"module": "Preserve",
|
|
10
|
+
"moduleResolution": "Bundler",
|
|
11
|
+
"jsx": "preserve",
|
|
12
|
+
"jsxImportSource": "@atom.io/spa",
|
|
13
|
+
"types": [
|
|
14
|
+
"astro/client",
|
|
15
|
+
"vite/client",
|
|
16
|
+
"node"
|
|
17
|
+
],
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"exactOptionalPropertyTypes": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
"paths": {}
|
|
22
|
+
},
|
|
23
|
+
"include": [
|
|
24
|
+
"src",
|
|
25
|
+
"astro.config.ts",
|
|
26
|
+
"scripts",
|
|
27
|
+
"tests/types.ts"
|
|
28
|
+
],
|
|
29
|
+
"exclude": [
|
|
30
|
+
"dist",
|
|
31
|
+
"node_modules"
|
|
32
|
+
],
|
|
33
|
+
"contentMappers": [
|
|
34
|
+
{
|
|
35
|
+
"package": "reylo",
|
|
36
|
+
"extensions": [
|
|
37
|
+
".sql",
|
|
38
|
+
".reylo.yaml",
|
|
39
|
+
".reylo.yml"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|