ajo-kit 0.1.0
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/LICENSE +15 -0
- package/README.md +468 -0
- package/dist/bin/kit.js +109 -0
- package/dist/chunks/constants-5Eu0naK3.js +168 -0
- package/dist/chunks/discover-D-b8Pqm8.js +34 -0
- package/dist/chunks/headers-BDszzwLw.js +23 -0
- package/dist/chunks/migrate-C9Iytqx4.js +89 -0
- package/dist/chunks/ssr-CCxCGcm6.js +421 -0
- package/dist/client.js +134 -0
- package/dist/database.js +38 -0
- package/dist/index.js +2 -0
- package/dist/mail.js +15 -0
- package/dist/node.js +104 -0
- package/dist/server.js +502 -0
- package/dist/validate.js +15 -0
- package/dist/vite.js +110 -0
- package/package.json +106 -0
- package/src/app.tsx +484 -0
- package/src/cache.ts +85 -0
- package/src/client.tsx +167 -0
- package/src/constants.ts +339 -0
- package/src/database.ts +55 -0
- package/src/discover.ts +42 -0
- package/src/form.ts +39 -0
- package/src/freshness.ts +63 -0
- package/src/head.tsx +125 -0
- package/src/headers.ts +32 -0
- package/src/index.ts +31 -0
- package/src/mail/index.ts +25 -0
- package/src/migrate.ts +132 -0
- package/src/node.ts +134 -0
- package/src/server.tsx +584 -0
- package/src/ssr.ts +8 -0
- package/src/timing.ts +60 -0
- package/src/validate.ts +33 -0
- package/src/virtual.d.ts +10 -0
- package/src/vite.ts +166 -0
package/dist/vite.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { t as discover } from "./chunks/discover-D-b8Pqm8.js";
|
|
2
|
+
//#region packages/ajo-kit/src/vite.ts
|
|
3
|
+
var match = (id, pattern) => typeof pattern === "function" ? pattern(id) : typeof pattern === "string" ? id.includes(pattern) : pattern.test(id);
|
|
4
|
+
var any = (id, patterns) => patterns.some((p) => match(id, p));
|
|
5
|
+
/**
|
|
6
|
+
* Prevents server-only modules from being imported into client code.
|
|
7
|
+
* Tracks the import chain to catch transitive imports through barrel files.
|
|
8
|
+
*/
|
|
9
|
+
var guard = (patterns) => {
|
|
10
|
+
const chain = /* @__PURE__ */ new Map();
|
|
11
|
+
return {
|
|
12
|
+
name: "ajo-server-only",
|
|
13
|
+
enforce: "pre",
|
|
14
|
+
resolveId: {
|
|
15
|
+
order: "pre",
|
|
16
|
+
async handler(source, importer) {
|
|
17
|
+
if (this.environment.name !== "client") return;
|
|
18
|
+
if (!importer) return;
|
|
19
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true });
|
|
20
|
+
if (!resolved) return;
|
|
21
|
+
const id = resolved.id;
|
|
22
|
+
chain.set(id, importer);
|
|
23
|
+
if (/\.client\.[jt]sx?$/.test(id)) return;
|
|
24
|
+
if (any(id, patterns)) {
|
|
25
|
+
const trace = [id];
|
|
26
|
+
let current = importer;
|
|
27
|
+
while (current && trace.length < 10) {
|
|
28
|
+
trace.unshift(current);
|
|
29
|
+
current = chain.get(current);
|
|
30
|
+
}
|
|
31
|
+
const path = trace.map((p) => p.replace(/^.*\/src\//, "src/")).join("\n → ");
|
|
32
|
+
throw new Error(`Server-only module imported into client code:\n\n ${path}\n\nModule "${id.replace(/^.*\/src\//, "src/")}" cannot be imported by client code.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
var hmr = (pattern) => ({
|
|
39
|
+
name: "ajo-hmr",
|
|
40
|
+
apply: "serve",
|
|
41
|
+
transform(code, id) {
|
|
42
|
+
if (!pattern.test(id)) return null;
|
|
43
|
+
const HMR = `Symbol.for('ajo.hmr')`;
|
|
44
|
+
const path = "/" + id.replace(/^.*?(src\/)/, "$1");
|
|
45
|
+
return {
|
|
46
|
+
code: (code.match(/export\s+default\s+(\w+)\s*[\n;]/) ? code.replace(/export\s+default\s+(\w+)/, `export default $1;$1[${HMR}]=${JSON.stringify(path)}`) : code) + `
|
|
47
|
+
if(import.meta.hot)import.meta.hot.accept(m=>{
|
|
48
|
+
if(m?.default)m.default[${HMR}]=${JSON.stringify(path)};
|
|
49
|
+
if(m)(globalThis.__MODULES__??=new Map).set(${JSON.stringify(path)},m),globalThis.__HMR__?.(${JSON.stringify(path)});
|
|
50
|
+
})`,
|
|
51
|
+
map: null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
/** Default file locations used by the kit CLI. */
|
|
56
|
+
var defaults = {
|
|
57
|
+
database: "./database.sqlite",
|
|
58
|
+
migrations: "db/migrations",
|
|
59
|
+
seeds: "db/seeds"
|
|
60
|
+
};
|
|
61
|
+
var guards = (found) => [/(handler|wares)\.[jt]sx?$/, ...found.filter((p) => p.serverOnly).map((p) => new RegExp(`${p.name}/`))];
|
|
62
|
+
/** Returns the Vite plugins required by an ajo-kit app. */
|
|
63
|
+
function kit(options) {
|
|
64
|
+
const css = options?.css ?? [];
|
|
65
|
+
const found = discover();
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
name: "ajo-kit",
|
|
69
|
+
resolveId(id) {
|
|
70
|
+
if (id === "virtual:ajo/routes") return "\0virtual:ajo/routes";
|
|
71
|
+
if (id === "virtual:ajo/handlers") return "\0virtual:ajo/handlers";
|
|
72
|
+
},
|
|
73
|
+
load(id) {
|
|
74
|
+
if (id === "\0virtual:ajo/routes") return "export const routes = import.meta.glob('/src/**/{layout,page}.{j,t}s{,x}')";
|
|
75
|
+
if (id === "\0virtual:ajo/handlers") return ["export const handlers = import.meta.glob('/src/**/handler.{j,t}s{,x}')", "export const wares = import.meta.glob('/src/**/wares.{j,t}s{,x}')"].join("\n");
|
|
76
|
+
},
|
|
77
|
+
transform(code, id) {
|
|
78
|
+
if (css.length && id.includes("ajo-kit") && id.endsWith("client.tsx")) return css.map((c) => `import '${c}'`).join("\n") + "\n" + code;
|
|
79
|
+
},
|
|
80
|
+
config() {
|
|
81
|
+
return {
|
|
82
|
+
ssr: { noExternal: [/^ajo-/] },
|
|
83
|
+
resolve: { alias: [
|
|
84
|
+
...found.filter((p) => p.alias).map((p) => ({
|
|
85
|
+
find: new RegExp(`^@kit/${p.alias}(/|$)`),
|
|
86
|
+
replacement: `${p.name}$1`
|
|
87
|
+
})),
|
|
88
|
+
{
|
|
89
|
+
find: /^@kit(\/|$)/,
|
|
90
|
+
replacement: "ajo-kit$1"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
find: "/src/client",
|
|
94
|
+
replacement: "ajo-kit/client"
|
|
95
|
+
}
|
|
96
|
+
] }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
guard([...guards(found), ...options?.guard ?? []]),
|
|
101
|
+
hmr(/(page|layout)\.[jt]sx?$/)
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/** Vite esbuild JSX settings for Ajo components. */
|
|
105
|
+
var jsx = {
|
|
106
|
+
jsx: "automatic",
|
|
107
|
+
jsxImportSource: "ajo"
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
export { defaults, jsx, kit };
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ajo-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Full-stack metaframework for Ajo applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"kit": "./dist/bin/kit.js"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./dist/index.js",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./server": {
|
|
21
|
+
"types": "./src/server.tsx",
|
|
22
|
+
"default": "./dist/server.js",
|
|
23
|
+
"import": "./dist/server.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./src/client.tsx",
|
|
27
|
+
"default": "./dist/client.js",
|
|
28
|
+
"import": "./dist/client.js"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./src/node.ts",
|
|
32
|
+
"default": "./dist/node.js",
|
|
33
|
+
"import": "./dist/node.js"
|
|
34
|
+
},
|
|
35
|
+
"./validate": {
|
|
36
|
+
"types": "./src/validate.ts",
|
|
37
|
+
"default": "./dist/validate.js",
|
|
38
|
+
"import": "./dist/validate.js"
|
|
39
|
+
},
|
|
40
|
+
"./vite": {
|
|
41
|
+
"types": "./src/vite.ts",
|
|
42
|
+
"default": "./dist/vite.js",
|
|
43
|
+
"import": "./dist/vite.js"
|
|
44
|
+
},
|
|
45
|
+
"./mail": {
|
|
46
|
+
"types": "./src/mail/index.ts",
|
|
47
|
+
"default": "./dist/mail.js",
|
|
48
|
+
"import": "./dist/mail.js"
|
|
49
|
+
},
|
|
50
|
+
"./database": {
|
|
51
|
+
"types": "./src/database.ts",
|
|
52
|
+
"default": "./dist/database.js",
|
|
53
|
+
"import": "./dist/database.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@polka/parse": "1.0.0-next.28",
|
|
58
|
+
"@polka/send": "1.0.0-next.28",
|
|
59
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
60
|
+
"better-sqlite3": "^12.11.1",
|
|
61
|
+
"devalue": "^5.8.1",
|
|
62
|
+
"dotenv": "^17.4.2",
|
|
63
|
+
"kysely": "^0.29.2",
|
|
64
|
+
"navaid": "^1.2.0",
|
|
65
|
+
"polka": "1.0.0-next.28",
|
|
66
|
+
"sade": "^1.8.1",
|
|
67
|
+
"sirv": "^3.0.2",
|
|
68
|
+
"valibot": "^1.4.1"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"ajo": "^0.1.35",
|
|
72
|
+
"vite": "^8.0.16"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"ajo": "0.1.35",
|
|
76
|
+
"vite": "8.0.16"
|
|
77
|
+
},
|
|
78
|
+
"engines": {
|
|
79
|
+
"node": ">=22.18.0"
|
|
80
|
+
},
|
|
81
|
+
"keywords": [
|
|
82
|
+
"ajo",
|
|
83
|
+
"framework",
|
|
84
|
+
"vite",
|
|
85
|
+
"sqlite",
|
|
86
|
+
"ssr"
|
|
87
|
+
],
|
|
88
|
+
"author": "Cristian Falcone",
|
|
89
|
+
"license": "ISC",
|
|
90
|
+
"repository": {
|
|
91
|
+
"type": "git",
|
|
92
|
+
"url": "git+https://github.com/cristianfalcone/ajo-kit.git",
|
|
93
|
+
"directory": "packages/ajo-kit"
|
|
94
|
+
},
|
|
95
|
+
"bugs": {
|
|
96
|
+
"url": "https://github.com/cristianfalcone/ajo-kit/issues"
|
|
97
|
+
},
|
|
98
|
+
"homepage": "https://github.com/cristianfalcone/ajo-kit/tree/main/packages/ajo-kit#readme",
|
|
99
|
+
"publishConfig": {
|
|
100
|
+
"access": "public"
|
|
101
|
+
},
|
|
102
|
+
"scripts": {
|
|
103
|
+
"build": "pnpm -w exec tsx scripts/package-build.ts ajo-kit",
|
|
104
|
+
"test": "pnpm -w exec vitest run packages/ajo-kit/tests"
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import navaid from 'navaid'
|
|
2
|
+
import type { Component, Stateful } from 'ajo'
|
|
3
|
+
import { Failure, navigate, ancestors } from './constants'
|
|
4
|
+
import type {
|
|
5
|
+
PageArgs,
|
|
6
|
+
LayoutArgs,
|
|
7
|
+
Data,
|
|
8
|
+
Module,
|
|
9
|
+
Loader,
|
|
10
|
+
Page,
|
|
11
|
+
State,
|
|
12
|
+
Payload,
|
|
13
|
+
} from './constants'
|
|
14
|
+
import { apply, type Head } from './head'
|
|
15
|
+
import { get, set } from './cache'
|
|
16
|
+
import { routes } from 'virtual:ajo/routes'
|
|
17
|
+
|
|
18
|
+
// Pattern compilation
|
|
19
|
+
|
|
20
|
+
const group = /^\(.*\)$/
|
|
21
|
+
const dynamic = /^\[(.+?)\]$/
|
|
22
|
+
|
|
23
|
+
export const match = (segments: string[]) =>
|
|
24
|
+
segments
|
|
25
|
+
.filter(segment => segment && !group.test(segment))
|
|
26
|
+
.map(segment => segment.replace(dynamic, (_, name) => name === '...' ? '*' : `:${name}`))
|
|
27
|
+
.join('/')
|
|
28
|
+
|
|
29
|
+
export const parts = (path: string) => path.slice(4).split('/').slice(0, -1)
|
|
30
|
+
|
|
31
|
+
let initial: State | undefined
|
|
32
|
+
|
|
33
|
+
export function init(state: State | null) {
|
|
34
|
+
initial = state ?? undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const error: () => Page = () => ({
|
|
38
|
+
segments: [''],
|
|
39
|
+
loader: async () => ({ default: () => null }),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Build pages from file system
|
|
43
|
+
|
|
44
|
+
export const layouts = new Map<string, Loader>()
|
|
45
|
+
export const pages: Page[] = []
|
|
46
|
+
|
|
47
|
+
// Path helpers
|
|
48
|
+
|
|
49
|
+
export const parents = (segments: string[]) => ancestors(segments).filter(path => layouts.has(path))
|
|
50
|
+
|
|
51
|
+
for (const [path, loader] of Object.entries(routes as Record<string, Loader>)) {
|
|
52
|
+
|
|
53
|
+
const segments = parts(path)
|
|
54
|
+
const kind = path.split('/').pop()?.split('.')[0]
|
|
55
|
+
|
|
56
|
+
if (kind === 'layout') layouts.set(segments.join('/'), loader)
|
|
57
|
+
if (kind === 'page') pages.push({ pattern: match(segments), segments, loader })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// HMR: wrap loaders to use hot-updated modules
|
|
61
|
+
|
|
62
|
+
if (import.meta.env.DEV && !import.meta.env.SSR) {
|
|
63
|
+
|
|
64
|
+
const scope = globalThis as { __MODULES__?: Map<string, Module> }
|
|
65
|
+
const modules = scope.__MODULES__ ??= new Map()
|
|
66
|
+
|
|
67
|
+
const hmr = (loader: Loader, file: string): Loader => async () => {
|
|
68
|
+
|
|
69
|
+
if (modules.has(file)) return modules.get(file)
|
|
70
|
+
|
|
71
|
+
const module = await loader()
|
|
72
|
+
|
|
73
|
+
modules.set(file, module)
|
|
74
|
+
|
|
75
|
+
return module
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [path, loader] of layouts) layouts.set(path, hmr(loader, `/src${path}/layout.tsx`))
|
|
79
|
+
|
|
80
|
+
for (const page of pages) page.loader = hmr(page.loader, `/src${page.segments.join('/')}/page.tsx`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Compose component tree
|
|
84
|
+
|
|
85
|
+
function compose(
|
|
86
|
+
page: Module,
|
|
87
|
+
tree: Array<{ path: string; module: Module }>,
|
|
88
|
+
paths: string[],
|
|
89
|
+
state: State
|
|
90
|
+
): Component {
|
|
91
|
+
|
|
92
|
+
const Page = page.default as Component<PageArgs>
|
|
93
|
+
|
|
94
|
+
// Find who handles pending navigation: page first, then innermost layout.
|
|
95
|
+
|
|
96
|
+
const boundary = page.pending ? 'page' : tree.findLast(entry => entry.module.pending)?.path
|
|
97
|
+
|
|
98
|
+
return tree.reduceRight<Component>(
|
|
99
|
+
(Child, { path, module }, depth) => {
|
|
100
|
+
const Layout = module.default as Component<LayoutArgs>
|
|
101
|
+
return () => (
|
|
102
|
+
<Layout
|
|
103
|
+
key={path}
|
|
104
|
+
params={state.params}
|
|
105
|
+
data={state.data[depth]}
|
|
106
|
+
loading={state.loading && boundary === path}
|
|
107
|
+
error={state.error}
|
|
108
|
+
>
|
|
109
|
+
<Child />
|
|
110
|
+
</Layout>
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
() => (
|
|
114
|
+
<Page
|
|
115
|
+
key={paths.join('/')}
|
|
116
|
+
params={state.params}
|
|
117
|
+
data={state.data.at(-1)}
|
|
118
|
+
loading={state.loading && boundary === 'page'}
|
|
119
|
+
error={state.error}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type Load = {
|
|
126
|
+
data: Data
|
|
127
|
+
head?: Head
|
|
128
|
+
hash?: string
|
|
129
|
+
topics?: string[]
|
|
130
|
+
versions?: Record<string, number>
|
|
131
|
+
redirect?: string
|
|
132
|
+
error?: Failure
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function load(url: string): Promise<Load> {
|
|
136
|
+
|
|
137
|
+
const cached = get(url)
|
|
138
|
+
const versions = cached?.versions ? JSON.stringify(cached.versions) : undefined
|
|
139
|
+
|
|
140
|
+
const response = await fetch(url, {
|
|
141
|
+
credentials: 'include',
|
|
142
|
+
cache: 'no-store',
|
|
143
|
+
headers: {
|
|
144
|
+
Accept: 'application/json',
|
|
145
|
+
...(cached?.hash && { 'X-Have': cached.hash }),
|
|
146
|
+
...(versions && { 'X-Ajo-Versions': versions })
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (response.status === 304 && cached) {
|
|
151
|
+
return {
|
|
152
|
+
data: cached.data,
|
|
153
|
+
head: cached.head,
|
|
154
|
+
hash: cached.hash,
|
|
155
|
+
topics: cached.topics,
|
|
156
|
+
versions: cached.versions
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const json = await response.json().catch(() => null) as
|
|
161
|
+
| { data?: Data; head?: Head; hash?: string; topics?: string[]; versions?: Record<string, number>; redirect?: string; error?: { status?: number; message?: string } }
|
|
162
|
+
| null
|
|
163
|
+
|
|
164
|
+
if (!json || !response.ok) {
|
|
165
|
+
return {
|
|
166
|
+
data: [],
|
|
167
|
+
error: new Failure(
|
|
168
|
+
json?.error?.status ?? response.status,
|
|
169
|
+
json?.error?.message ?? 'Load failed'
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (json.redirect) return { data: [], redirect: json.redirect }
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
data: json.data ?? [],
|
|
178
|
+
head: json.head,
|
|
179
|
+
hash: json.hash,
|
|
180
|
+
topics: json.topics,
|
|
181
|
+
versions: json.versions
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resolve page: async generator yielding loading then data states
|
|
186
|
+
|
|
187
|
+
export async function* resolve(
|
|
188
|
+
url: string,
|
|
189
|
+
layouts: Map<string, Loader>,
|
|
190
|
+
page: Page,
|
|
191
|
+
data?: Data,
|
|
192
|
+
error?: Failure
|
|
193
|
+
): AsyncGenerator<{ page: Component; state?: State }> {
|
|
194
|
+
|
|
195
|
+
const { loader, segments, params = {} } = page
|
|
196
|
+
|
|
197
|
+
const paths = parents(segments)
|
|
198
|
+
|
|
199
|
+
const [target, ...tree] = await Promise.all([
|
|
200
|
+
loader(),
|
|
201
|
+
...paths.map(path => layouts.get(path)!().then(module => ({ path, module })))
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
if (error) {
|
|
205
|
+
const state: State = { url, params, data: [], loading: false, error }
|
|
206
|
+
yield { page: compose(target, tree, paths, state), state }
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const cached = initial?.url === url ? initial : undefined
|
|
211
|
+
|
|
212
|
+
if (cached) {
|
|
213
|
+
|
|
214
|
+
initial = undefined
|
|
215
|
+
if (cached.hash) set(url, cached)
|
|
216
|
+
|
|
217
|
+
yield {
|
|
218
|
+
page: compose(target, tree, paths, cached),
|
|
219
|
+
state: cached,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
yield { page: compose(target, tree, paths, { url, params, data: [], loading: true }) }
|
|
226
|
+
|
|
227
|
+
const server: Load = data
|
|
228
|
+
? { data }
|
|
229
|
+
: import.meta.env.SSR
|
|
230
|
+
? { data: [] }
|
|
231
|
+
: await load(url)
|
|
232
|
+
|
|
233
|
+
if (server.redirect) {
|
|
234
|
+
navigate(server.redirect)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (server.error) {
|
|
239
|
+
const state = { url, params, data: [], loading: false, error: server.error }
|
|
240
|
+
yield { page: compose(target, tree, paths, state), state }
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const state: State = {
|
|
245
|
+
url,
|
|
246
|
+
params,
|
|
247
|
+
data: server.data,
|
|
248
|
+
loading: false,
|
|
249
|
+
head: server.head,
|
|
250
|
+
hash: server.hash,
|
|
251
|
+
topics: server.topics,
|
|
252
|
+
versions: server.versions,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (state.hash) set(url, state)
|
|
256
|
+
|
|
257
|
+
yield {
|
|
258
|
+
page: compose(target, tree, paths, state),
|
|
259
|
+
state
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type Message = {
|
|
264
|
+
data: Payload
|
|
265
|
+
hash?: string
|
|
266
|
+
topics?: string[]
|
|
267
|
+
versions?: Record<string, number>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type Status = 'closed' | 'connecting' | 'open'
|
|
271
|
+
|
|
272
|
+
type Detail = {
|
|
273
|
+
topics?: string[]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stream(update: (message: Message) => void, notify?: (status: Status) => void) {
|
|
277
|
+
|
|
278
|
+
let source: EventSource | null = null
|
|
279
|
+
|
|
280
|
+
const status = (value: Status) => notify?.(value)
|
|
281
|
+
|
|
282
|
+
const connect = (path: string) => {
|
|
283
|
+
|
|
284
|
+
source?.close()
|
|
285
|
+
|
|
286
|
+
if ((globalThis as { __AJO_DISABLE_SSE__?: boolean }).__AJO_DISABLE_SSE__) {
|
|
287
|
+
status('closed')
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
status('connecting')
|
|
292
|
+
|
|
293
|
+
source = new EventSource(path)
|
|
294
|
+
|
|
295
|
+
source.onopen = () => status('open')
|
|
296
|
+
|
|
297
|
+
source.onmessage = event => {
|
|
298
|
+
const message = JSON.parse(event.data) as Message
|
|
299
|
+
if (message.data) update(message)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
source.onerror = () => status('connecting')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const close = () => {
|
|
306
|
+
source?.close()
|
|
307
|
+
source = null
|
|
308
|
+
status('closed')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { connect, close }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const App: Stateful<{ page?: Component }> = function* ({ page }) {
|
|
315
|
+
|
|
316
|
+
let Page: Component = page ?? (() => null)
|
|
317
|
+
|
|
318
|
+
if (page) return <Page />
|
|
319
|
+
|
|
320
|
+
let hmr = false
|
|
321
|
+
let active: State | null = null
|
|
322
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
323
|
+
let generation = 0
|
|
324
|
+
let live = 0
|
|
325
|
+
let phase: Status = 'closed'
|
|
326
|
+
|
|
327
|
+
const sse = stream(message => {
|
|
328
|
+
|
|
329
|
+
if (!active || !message.data) return
|
|
330
|
+
|
|
331
|
+
live++
|
|
332
|
+
|
|
333
|
+
const [head, ...entries] = message.data
|
|
334
|
+
|
|
335
|
+
active.data = entries
|
|
336
|
+
active.hash = message.hash ?? active.hash
|
|
337
|
+
active.topics = message.topics ?? active.topics
|
|
338
|
+
active.versions = message.versions ?? active.versions
|
|
339
|
+
|
|
340
|
+
if (head) apply(active.head = head)
|
|
341
|
+
|
|
342
|
+
if (active.hash) set(active.url, active)
|
|
343
|
+
|
|
344
|
+
this.next()
|
|
345
|
+
}, status => phase = status)
|
|
346
|
+
|
|
347
|
+
const go = async (target: Page, options: { scroll?: boolean } = {}) => {
|
|
348
|
+
|
|
349
|
+
const gen = ++generation
|
|
350
|
+
const url = location.pathname + location.search
|
|
351
|
+
const scroll = options.scroll ?? true
|
|
352
|
+
|
|
353
|
+
sse.close()
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
|
|
357
|
+
for await (const { page, state } of resolve(url, layouts, target)) {
|
|
358
|
+
|
|
359
|
+
if (gen !== generation) return
|
|
360
|
+
if (hmr && !state) continue
|
|
361
|
+
|
|
362
|
+
this.next(() => Page = page)
|
|
363
|
+
|
|
364
|
+
if (state?.head) apply(state.head)
|
|
365
|
+
|
|
366
|
+
if (state && !state.loading) {
|
|
367
|
+
active = state
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
} catch (err) {
|
|
372
|
+
|
|
373
|
+
if (gen !== generation) return
|
|
374
|
+
|
|
375
|
+
err = err instanceof Failure ? err : new Failure(500, err instanceof Error ? err.message : 'Navigation failed')
|
|
376
|
+
|
|
377
|
+
for await (const { page } of resolve(url, layouts, error(), undefined, err as Failure)) {
|
|
378
|
+
|
|
379
|
+
if (gen !== generation) return
|
|
380
|
+
|
|
381
|
+
this.next(() => Page = page)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (gen !== generation) return
|
|
388
|
+
|
|
389
|
+
if (!hmr) {
|
|
390
|
+
sse.connect(url)
|
|
391
|
+
if (scroll) requestAnimationFrame(() => scrollTo({ top: 0, behavior: 'smooth' }))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
hmr = false
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const refresh = async () => {
|
|
398
|
+
if (!active) return
|
|
399
|
+
|
|
400
|
+
const gen = generation
|
|
401
|
+
const state = active
|
|
402
|
+
const server = await load(state.url)
|
|
403
|
+
|
|
404
|
+
if (gen !== generation || active !== state) return
|
|
405
|
+
|
|
406
|
+
if (server.redirect) {
|
|
407
|
+
navigate(server.redirect)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (server.error) {
|
|
412
|
+
state.data = []
|
|
413
|
+
state.error = server.error
|
|
414
|
+
state.loading = false
|
|
415
|
+
} else {
|
|
416
|
+
state.data = server.data
|
|
417
|
+
state.error = undefined
|
|
418
|
+
state.head = server.head
|
|
419
|
+
state.hash = server.hash
|
|
420
|
+
state.topics = server.topics
|
|
421
|
+
state.versions = server.versions
|
|
422
|
+
|
|
423
|
+
if (server.head) apply(server.head)
|
|
424
|
+
if (state.hash) set(state.url, state)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.next()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const reconcile = (topics?: string[]) => {
|
|
431
|
+
|
|
432
|
+
if (!topics?.length || !active?.topics?.length) return
|
|
433
|
+
|
|
434
|
+
const changed = new Set(topics)
|
|
435
|
+
|
|
436
|
+
if (!active.topics.some(topic => changed.has(topic))) return
|
|
437
|
+
|
|
438
|
+
const seen = live
|
|
439
|
+
const delay = phase === 'open' ? 250 : 0
|
|
440
|
+
|
|
441
|
+
if (timer) clearTimeout(timer)
|
|
442
|
+
|
|
443
|
+
timer = setTimeout(() => {
|
|
444
|
+
timer = null
|
|
445
|
+
if (live !== seen) return
|
|
446
|
+
void refresh()
|
|
447
|
+
}, delay)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const router = navaid('/', () => go(error()))
|
|
451
|
+
|
|
452
|
+
for (const config of pages) router.on(config.pattern!, params => go({ ...config, params }))
|
|
453
|
+
|
|
454
|
+
router.listen()
|
|
455
|
+
|
|
456
|
+
if (import.meta.env.DEV) addEventListener(
|
|
457
|
+
'hmr',
|
|
458
|
+
() => {
|
|
459
|
+
hmr = true
|
|
460
|
+
router.run()
|
|
461
|
+
},
|
|
462
|
+
{ signal: this.signal }
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
addEventListener('ajo:navigate', () => router.run(), { signal: this.signal })
|
|
466
|
+
|
|
467
|
+
addEventListener(
|
|
468
|
+
'ajo:action',
|
|
469
|
+
event => reconcile((event as CustomEvent<Detail>).detail?.topics),
|
|
470
|
+
{ signal: this.signal }
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
this.signal.addEventListener('abort', () => {
|
|
474
|
+
if (timer) clearTimeout(timer)
|
|
475
|
+
sse.close()
|
|
476
|
+
router.unlisten?.()
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
while (true) yield <Page />
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
App.attrs = { class: 'h-full' }
|
|
483
|
+
|
|
484
|
+
export default App
|