pastoria 0.0.1 → 1.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/CHANGELOG.md +7 -0
- package/LICENSE +20 -0
- package/package.json +22 -6
- package/src/build.mts +127 -0
- package/src/devserver.mts +53 -0
- package/src/generate.mts +277 -0
- package/src/index.mts +28 -295
- package/templates/router.tsx +87 -19
- package/tsconfig.json +2 -1
- package/templates/server_router.ts +0 -112
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ryan Delaney
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pastoria",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Pastoria Development CLI",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"type": "module",
|
|
5
|
-
"bin":
|
|
7
|
+
"bin": {
|
|
8
|
+
"pastoria": "src/index.mts"
|
|
9
|
+
},
|
|
6
10
|
"dependencies": {
|
|
11
|
+
"@tailwindcss/vite": "^4.1.14",
|
|
12
|
+
"@vitejs/plugin-react": "^5.0.4",
|
|
13
|
+
"commander": "^14.0.1",
|
|
14
|
+
"cookie-parser": "^1.4.7",
|
|
15
|
+
"dotenv": "^16.6.1",
|
|
16
|
+
"express": "^5.1.0",
|
|
7
17
|
"picocolors": "^1.1.1",
|
|
8
|
-
"ts-morph": "^26.0.0"
|
|
18
|
+
"ts-morph": "^26.0.0",
|
|
19
|
+
"vite": "^7.1.9",
|
|
20
|
+
"vite-plugin-cjs-interop": "^2.3.0"
|
|
9
21
|
},
|
|
10
22
|
"devDependencies": {
|
|
11
|
-
"@types/node": "
|
|
12
|
-
"typescript": "^5.9.2"
|
|
23
|
+
"@types/node": "^22.12.0",
|
|
24
|
+
"typescript": "^5.9.2",
|
|
25
|
+
"pastoria-runtime": "1.0.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"check:types": "tsc --noEmit"
|
|
13
29
|
}
|
|
14
|
-
}
|
|
30
|
+
}
|
package/src/build.mts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import react from '@vitejs/plugin-react';
|
|
2
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
3
|
+
import {cjsInterop} from 'vite-plugin-cjs-interop';
|
|
4
|
+
import {build, type BuildEnvironmentOptions, type Plugin} from 'vite';
|
|
5
|
+
|
|
6
|
+
// TODO: Only emit `App` code if _app exits.
|
|
7
|
+
const PASTORIA_CLIENT_ENTRY = `// Generated by Pastoria.
|
|
8
|
+
import {createRouterApp} from '#genfiles/router/router';
|
|
9
|
+
import {App} from '#src/pages/_app';
|
|
10
|
+
import {hydrateRoot} from 'react-dom/client';
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
const RouterApp = await createRouterApp();
|
|
14
|
+
hydrateRoot(document, <RouterApp App={App} />);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main();
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
// TODO: Remove hard-coded context import here.
|
|
21
|
+
const PASTORIA_ENTRY_SERVER = `// Generated by Pastoria.
|
|
22
|
+
import {JSResource} from '#genfiles/router/js_resource';
|
|
23
|
+
import {
|
|
24
|
+
listRoutes,
|
|
25
|
+
router__createAppFromEntryPoint,
|
|
26
|
+
router__loadEntryPoint,
|
|
27
|
+
} from '#genfiles/router/router';
|
|
28
|
+
import {getSchema} from '#genfiles/schema/schema';
|
|
29
|
+
import {Context} from '#src/lib/server/context';
|
|
30
|
+
import {App} from '#src/pages/_app';
|
|
31
|
+
import {GraphQLSchema, specifiedDirectives} from 'graphql';
|
|
32
|
+
import {createRouterHandler} from 'pastoria-runtime/server';
|
|
33
|
+
import type {Manifest} from 'vite';
|
|
34
|
+
|
|
35
|
+
const schemaConfig = getSchema().toConfig();
|
|
36
|
+
const schema = new GraphQLSchema({
|
|
37
|
+
...schemaConfig,
|
|
38
|
+
directives: [...specifiedDirectives, ...schemaConfig.directives],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function createHandler(
|
|
42
|
+
persistedQueries: Record<string, string>,
|
|
43
|
+
manifest?: Manifest,
|
|
44
|
+
) {
|
|
45
|
+
return createRouterHandler(
|
|
46
|
+
listRoutes(),
|
|
47
|
+
JSResource.srcOfModuleId,
|
|
48
|
+
router__loadEntryPoint,
|
|
49
|
+
router__createAppFromEntryPoint,
|
|
50
|
+
App,
|
|
51
|
+
schema,
|
|
52
|
+
() => new Context(),
|
|
53
|
+
persistedQueries,
|
|
54
|
+
manifest,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
function pastoriaEntryPlugin(): Plugin {
|
|
60
|
+
const clientEntryModuleId = 'virtual:pastoria-entry-client.tsx';
|
|
61
|
+
const serverEntryModuleId = 'virtual:pastoria-entry-server.tsx';
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: 'pastoria-entry',
|
|
65
|
+
resolveId(id) {
|
|
66
|
+
if (id === clientEntryModuleId) {
|
|
67
|
+
return clientEntryModuleId; // Return without \0 prefix so React plugin can see .tsx extension
|
|
68
|
+
} else if (id === serverEntryModuleId) {
|
|
69
|
+
return serverEntryModuleId;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
load(id) {
|
|
73
|
+
if (id === clientEntryModuleId) {
|
|
74
|
+
return PASTORIA_CLIENT_ENTRY;
|
|
75
|
+
} else if (id === serverEntryModuleId) {
|
|
76
|
+
return PASTORIA_ENTRY_SERVER;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const CLIENT_BUILD: BuildEnvironmentOptions = {
|
|
83
|
+
outDir: 'dist/client',
|
|
84
|
+
rollupOptions: {
|
|
85
|
+
input: 'virtual:pastoria-entry-client.tsx',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const SERVER_BUILD: BuildEnvironmentOptions = {
|
|
90
|
+
outDir: 'dist/server',
|
|
91
|
+
ssr: true,
|
|
92
|
+
rollupOptions: {
|
|
93
|
+
input: 'virtual:pastoria-entry-server.tsx',
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function createBuildConfig(buildEnv: BuildEnvironmentOptions) {
|
|
98
|
+
return {
|
|
99
|
+
appType: 'custom' as const,
|
|
100
|
+
build: {
|
|
101
|
+
...buildEnv,
|
|
102
|
+
assetsInlineLimit: 0,
|
|
103
|
+
manifest: true,
|
|
104
|
+
ssrManifest: true,
|
|
105
|
+
},
|
|
106
|
+
plugins: [
|
|
107
|
+
pastoriaEntryPlugin(),
|
|
108
|
+
tailwindcss(),
|
|
109
|
+
react({babel: {plugins: ['relay']}}),
|
|
110
|
+
cjsInterop({
|
|
111
|
+
dependencies: ['react-relay', 'react-relay/hooks', 'relay-runtime'],
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function createBuild() {
|
|
118
|
+
const clientBuild = await build({
|
|
119
|
+
...createBuildConfig(CLIENT_BUILD),
|
|
120
|
+
configFile: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const serverBuild = await build({
|
|
124
|
+
...createBuildConfig(SERVER_BUILD),
|
|
125
|
+
configFile: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import cookieParser from 'cookie-parser';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import {createServer as createViteServer, type Manifest} from 'vite';
|
|
7
|
+
import {CLIENT_BUILD, createBuildConfig} from './build.mts';
|
|
8
|
+
|
|
9
|
+
interface PersistedQueries {
|
|
10
|
+
[hash: string]: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ServerEntry {
|
|
14
|
+
createHandler(
|
|
15
|
+
persistedQueries: PersistedQueries,
|
|
16
|
+
manifest?: Manifest,
|
|
17
|
+
): express.Router;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function startDevserver(opts: {port: string}) {
|
|
21
|
+
dotenv.config();
|
|
22
|
+
|
|
23
|
+
const buildConfig = createBuildConfig(CLIENT_BUILD);
|
|
24
|
+
const vite = await createViteServer({
|
|
25
|
+
...buildConfig,
|
|
26
|
+
configFile: false,
|
|
27
|
+
server: {middlewareMode: true},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const app = express();
|
|
31
|
+
app.use(cookieParser());
|
|
32
|
+
app.use(vite.middlewares);
|
|
33
|
+
app.use(async (req, res, next) => {
|
|
34
|
+
const persistedQueries = JSON.parse(
|
|
35
|
+
await readFile('__generated__/persisted_queries.json', 'utf-8'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const {createHandler} = (await vite.ssrLoadModule(
|
|
39
|
+
'virtual:pastoria-entry-server.tsx',
|
|
40
|
+
)) as ServerEntry;
|
|
41
|
+
|
|
42
|
+
const handler = createHandler(persistedQueries);
|
|
43
|
+
handler(req, res, next);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
app.listen(Number(opts.port), (err) => {
|
|
47
|
+
if (err) {
|
|
48
|
+
console.error(err);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(pc.cyan(`Listening on port ${opts.port}!`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/generate.mts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Router Code Generator
|
|
3
|
+
*
|
|
4
|
+
* This script generates type-safe router configuration files by scanning TypeScript
|
|
5
|
+
* source code for JSDoc annotations. It's part of the "Pastoria" routing framework.
|
|
6
|
+
*
|
|
7
|
+
* How it works:
|
|
8
|
+
* 1. Scans all TypeScript files in the project for exported functions/classes
|
|
9
|
+
* 2. Looks for JSDoc tags: @route, @resource, and @param
|
|
10
|
+
* 3. Generates three files from templates:
|
|
11
|
+
* - js_resource.ts: Resource configuration for lazy loading
|
|
12
|
+
* - router.tsx: Client-side router with type-safe routes
|
|
13
|
+
* - server_router.ts: Server-side router configuration
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* - Add @route <route-name> to functions to create routes
|
|
17
|
+
* - Add @param <name> <type> to document route parameters
|
|
18
|
+
* - Add @resource <resource-name> to exports for lazy loading
|
|
19
|
+
*
|
|
20
|
+
* The generator automatically creates Zod schemas for route parameters based on
|
|
21
|
+
* TypeScript types, enabling runtime validation and type safety.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {readFile} from 'node:fs/promises';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import {default as pc} from 'picocolors';
|
|
27
|
+
import {Project, SourceFile, Symbol, SyntaxKind, ts, TypeFlags} from 'ts-morph';
|
|
28
|
+
|
|
29
|
+
const JS_RESOURCE_FILENAME = '__generated__/router/js_resource.ts';
|
|
30
|
+
const JS_RESOURCE_TEMPLATE = path.join(
|
|
31
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
32
|
+
'../templates/js_resource.ts',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const ROUTER_FILENAME = '__generated__/router/router.tsx';
|
|
36
|
+
const ROUTER_TEMPLATE = path.join(
|
|
37
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
38
|
+
'../templates/router.tsx',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
async function loadRouterFiles(project: Project) {
|
|
42
|
+
async function loadSourceFile(fileName: string, templateFileName: string) {
|
|
43
|
+
const template = await readFile(templateFileName, 'utf-8');
|
|
44
|
+
const warningComment = `/*
|
|
45
|
+
* This file was generated by \`pastoria\`.
|
|
46
|
+
* Do not modify this file directly. Instead, edit the template at ${path.basename(templateFileName)}.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
`;
|
|
50
|
+
return project.createSourceFile(fileName, warningComment + template, {
|
|
51
|
+
overwrite: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const [jsResource, router] = await Promise.all([
|
|
56
|
+
loadSourceFile(JS_RESOURCE_FILENAME, JS_RESOURCE_TEMPLATE),
|
|
57
|
+
loadSourceFile(ROUTER_FILENAME, ROUTER_TEMPLATE),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
return {jsResource, router} as const;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type RouterResource = {
|
|
64
|
+
resourceName: string;
|
|
65
|
+
sourceFile: SourceFile;
|
|
66
|
+
symbol: Symbol;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type RouterRoute = {
|
|
70
|
+
routeName: string;
|
|
71
|
+
sourceFile: SourceFile;
|
|
72
|
+
symbol: Symbol;
|
|
73
|
+
params: Map<string, ts.Type>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function collectRouterNodes(project: Project) {
|
|
77
|
+
const resources: RouterResource[] = [];
|
|
78
|
+
const routes: RouterRoute[] = [];
|
|
79
|
+
|
|
80
|
+
function visitRouterNodes(sourceFile: SourceFile) {
|
|
81
|
+
// TODO: Skip sourceFile if a pastora JSDoc tag isn't used at all.
|
|
82
|
+
sourceFile.getExportSymbols().forEach((symbol) => {
|
|
83
|
+
let routerResource = null as RouterResource | null;
|
|
84
|
+
let routerRoute = null as RouterRoute | null;
|
|
85
|
+
const routeParams = new Map<string, ts.Type>();
|
|
86
|
+
|
|
87
|
+
function visitJSDocTags(tag: ts.JSDoc | ts.JSDocTag) {
|
|
88
|
+
if (ts.isJSDoc(tag)) {
|
|
89
|
+
tag.tags?.forEach(visitJSDocTags);
|
|
90
|
+
} else if (ts.isJSDocParameterTag(tag)) {
|
|
91
|
+
const typeNode = tag.typeExpression?.type;
|
|
92
|
+
const tc = project.getTypeChecker().compilerObject;
|
|
93
|
+
|
|
94
|
+
const type =
|
|
95
|
+
typeNode == null
|
|
96
|
+
? tc.getUnknownType()
|
|
97
|
+
: tc.getTypeFromTypeNode(typeNode);
|
|
98
|
+
|
|
99
|
+
routeParams.set(tag.name.getText(), type);
|
|
100
|
+
} else if (typeof tag.comment === 'string') {
|
|
101
|
+
switch (tag.tagName.getText()) {
|
|
102
|
+
case 'route': {
|
|
103
|
+
routerRoute = {
|
|
104
|
+
routeName: tag.comment,
|
|
105
|
+
sourceFile,
|
|
106
|
+
symbol,
|
|
107
|
+
params: routeParams,
|
|
108
|
+
};
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'resource': {
|
|
112
|
+
routerResource = {
|
|
113
|
+
resourceName: tag.comment,
|
|
114
|
+
sourceFile,
|
|
115
|
+
symbol,
|
|
116
|
+
};
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
symbol
|
|
124
|
+
.getDeclarations()
|
|
125
|
+
.flatMap((decl) => ts.getJSDocCommentsAndTags(decl.compilerNode))
|
|
126
|
+
.forEach(visitJSDocTags);
|
|
127
|
+
|
|
128
|
+
if (routerRoute != null) routes.push(routerRoute);
|
|
129
|
+
if (routerResource != null) resources.push(routerResource);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
project.getSourceFiles().forEach(visitRouterNodes);
|
|
134
|
+
return {resources, routes} as const;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function zodSchemaOfType(tc: ts.TypeChecker, t: ts.Type): string {
|
|
138
|
+
if (t.getFlags() & TypeFlags.String) {
|
|
139
|
+
return `z.pipe(z.string(), z.transform(decodeURIComponent))`;
|
|
140
|
+
} else if (t.getFlags() & TypeFlags.Number) {
|
|
141
|
+
return `z.coerce.number<number>()`;
|
|
142
|
+
} else if (t.getFlags() & TypeFlags.Null) {
|
|
143
|
+
return `z.preprocess(s => s == null ? undefined : s, z.undefined())`;
|
|
144
|
+
} else if (t.isUnion()) {
|
|
145
|
+
const isRepresentingOptional =
|
|
146
|
+
t.types.length === 2 &&
|
|
147
|
+
t.types.some((s) => s.getFlags() & TypeFlags.Null);
|
|
148
|
+
|
|
149
|
+
if (isRepresentingOptional) {
|
|
150
|
+
const nonOptionalType = t.types.find(
|
|
151
|
+
(s) => !(s.getFlags() & TypeFlags.Null),
|
|
152
|
+
)!;
|
|
153
|
+
|
|
154
|
+
return `z.pipe(z.nullish(${zodSchemaOfType(tc, nonOptionalType)}), z.transform(s => s == null ? undefined : s))`;
|
|
155
|
+
} else {
|
|
156
|
+
return `z.union([${t.types.map((it) => zodSchemaOfType(tc, it)).join(', ')}])`;
|
|
157
|
+
}
|
|
158
|
+
} else if (tc.isArrayLikeType(t)) {
|
|
159
|
+
const typeArg = tc.getTypeArguments(t as ts.TypeReference)[0];
|
|
160
|
+
const argZodSchema =
|
|
161
|
+
typeArg == null ? `z.any()` : zodSchemaOfType(tc, typeArg);
|
|
162
|
+
|
|
163
|
+
return `z.array(${argZodSchema})`;
|
|
164
|
+
} else {
|
|
165
|
+
console.log('Could not handle type:', tc.typeToString(t));
|
|
166
|
+
return `z.any()`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function generatePastoriaArtifacts() {
|
|
171
|
+
const targetDir = process.cwd();
|
|
172
|
+
const project = new Project({
|
|
173
|
+
tsConfigFilePath: path.join(targetDir, 'tsconfig.json'),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const tc = project.getTypeChecker().compilerObject;
|
|
177
|
+
const routerFiles = await loadRouterFiles(project);
|
|
178
|
+
const routerNodes = collectRouterNodes(project);
|
|
179
|
+
|
|
180
|
+
const resourceConf = routerFiles.jsResource
|
|
181
|
+
.getVariableDeclarationOrThrow('RESOURCE_CONF')
|
|
182
|
+
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
183
|
+
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
184
|
+
|
|
185
|
+
resourceConf.getPropertyOrThrow('noop').remove();
|
|
186
|
+
for (const {resourceName, sourceFile, symbol} of routerNodes.resources) {
|
|
187
|
+
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
188
|
+
const moduleSpecifier =
|
|
189
|
+
routerFiles.jsResource.getRelativePathAsModuleSpecifierTo(
|
|
190
|
+
sourceFile.getFilePath(),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
resourceConf.addPropertyAssignment({
|
|
194
|
+
name: `"${resourceName}"`,
|
|
195
|
+
initializer: (writer) => {
|
|
196
|
+
writer.block(() => {
|
|
197
|
+
writer
|
|
198
|
+
.writeLine(`src: "${filePath}",`)
|
|
199
|
+
.writeLine(
|
|
200
|
+
`loader: () => import("${moduleSpecifier}").then(m => m.${symbol.getName()})`,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log(
|
|
207
|
+
'Created resource',
|
|
208
|
+
pc.cyan(resourceName),
|
|
209
|
+
'for',
|
|
210
|
+
pc.green(symbol.getName()),
|
|
211
|
+
'exported from',
|
|
212
|
+
pc.yellow(filePath),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const routerConf = routerFiles.router
|
|
217
|
+
.getVariableDeclarationOrThrow('ROUTER_CONF')
|
|
218
|
+
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
219
|
+
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
220
|
+
|
|
221
|
+
routerConf.getPropertyOrThrow('noop').remove();
|
|
222
|
+
|
|
223
|
+
let entryPointImportIndex = 0;
|
|
224
|
+
for (const {routeName, sourceFile, symbol, params} of routerNodes.routes) {
|
|
225
|
+
const importAlias = `e${entryPointImportIndex++}`;
|
|
226
|
+
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
227
|
+
const moduleSpecifier =
|
|
228
|
+
routerFiles.router.getRelativePathAsModuleSpecifierTo(
|
|
229
|
+
sourceFile.getFilePath(),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
routerFiles.router.addImportDeclaration({
|
|
233
|
+
moduleSpecifier,
|
|
234
|
+
namedImports: [
|
|
235
|
+
{
|
|
236
|
+
name: symbol.getName(),
|
|
237
|
+
alias: importAlias,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
routerConf.addPropertyAssignment({
|
|
243
|
+
name: `"${routeName}"`,
|
|
244
|
+
initializer: (writer) => {
|
|
245
|
+
writer
|
|
246
|
+
.write('{')
|
|
247
|
+
.indent(() => {
|
|
248
|
+
writer.writeLine(`entrypoint: ${importAlias},`);
|
|
249
|
+
if (params.size === 0) {
|
|
250
|
+
writer.writeLine(`schema: z.object({})`);
|
|
251
|
+
} else {
|
|
252
|
+
writer.writeLine(`schema: z.object({`);
|
|
253
|
+
for (const [paramName, paramType] of Array.from(params)) {
|
|
254
|
+
writer.writeLine(
|
|
255
|
+
` ${paramName}: ${zodSchemaOfType(tc, paramType)},`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
writer.writeLine('})');
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
.write('} as const');
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log(
|
|
267
|
+
'Created route',
|
|
268
|
+
pc.cyan(routeName),
|
|
269
|
+
'for',
|
|
270
|
+
pc.green(symbol.getName()),
|
|
271
|
+
'exported from',
|
|
272
|
+
pc.yellow(filePath),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await Promise.all([routerFiles.jsResource.save(), routerFiles.router.save()]);
|
|
277
|
+
}
|
package/src/index.mts
CHANGED
|
@@ -1,297 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node --experimental-strip-types
|
|
2
|
-
/**
|
|
3
|
-
* @fileoverview Router Code Generator
|
|
4
|
-
*
|
|
5
|
-
* This script generates type-safe router configuration files by scanning TypeScript
|
|
6
|
-
* source code for JSDoc annotations. It's part of the "Pastoria" routing framework.
|
|
7
|
-
*
|
|
8
|
-
* How it works:
|
|
9
|
-
* 1. Scans all TypeScript files in the project for exported functions/classes
|
|
10
|
-
* 2. Looks for JSDoc tags: @route, @resource, and @param
|
|
11
|
-
* 3. Generates three files from templates:
|
|
12
|
-
* - js_resource.ts: Resource configuration for lazy loading
|
|
13
|
-
* - router.tsx: Client-side router with type-safe routes
|
|
14
|
-
* - server_router.ts: Server-side router configuration
|
|
15
|
-
*
|
|
16
|
-
* Usage:
|
|
17
|
-
* - Add @route <route-name> to functions to create routes
|
|
18
|
-
* - Add @param <name> <type> to document route parameters
|
|
19
|
-
* - Add @resource <resource-name> to exports for lazy loading
|
|
20
|
-
*
|
|
21
|
-
* The generator automatically creates Zod schemas for route parameters based on
|
|
22
|
-
* TypeScript types, enabling runtime validation and type safety.
|
|
23
|
-
*
|
|
24
|
-
* Roadmap:
|
|
25
|
-
* 1. [DONE] Type-safe router APIs - Generate strongly typed navigation functions
|
|
26
|
-
* 2. Support for useTransition during routing - React 19 concurrent features
|
|
27
|
-
* 3. Support for metadata management in <head>
|
|
28
|
-
* 4. HTML manual generator suitable to be read by LLMs - Auto-generated docs
|
|
29
|
-
*/
|
|
30
2
|
|
|
31
|
-
import {
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* Do not modify this file directly. Instead, edit the template at ${path.basename(templateFileName)}.
|
|
60
|
-
*/
|
|
61
|
-
|
|
62
|
-
`;
|
|
63
|
-
return project.createSourceFile(fileName, warningComment + template, {
|
|
64
|
-
overwrite: true,
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const [jsResource, router, serverRouter] = await Promise.all([
|
|
69
|
-
loadSourceFile(JS_RESOURCE_FILENAME, JS_RESOURCE_TEMPLATE),
|
|
70
|
-
loadSourceFile(ROUTER_FILENAME, ROUTER_TEMPLATE),
|
|
71
|
-
loadSourceFile(SERVER_ROUTER_FILENAME, SERVER_ROUTER_TEMPLATE),
|
|
72
|
-
]);
|
|
73
|
-
|
|
74
|
-
return {jsResource, router, serverRouter} as const;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
type RouterResource = {
|
|
78
|
-
resourceName: string;
|
|
79
|
-
sourceFile: SourceFile;
|
|
80
|
-
symbol: Symbol;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
type RouterRoute = {
|
|
84
|
-
routeName: string;
|
|
85
|
-
sourceFile: SourceFile;
|
|
86
|
-
symbol: Symbol;
|
|
87
|
-
params: Map<string, ts.Type>;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
function collectRouterNodes(project: Project) {
|
|
91
|
-
const resources: RouterResource[] = [];
|
|
92
|
-
const routes: RouterRoute[] = [];
|
|
93
|
-
|
|
94
|
-
function visitRouterNodes(sourceFile: SourceFile) {
|
|
95
|
-
sourceFile.getExportSymbols().forEach((symbol) => {
|
|
96
|
-
let routerResource = null as RouterResource | null;
|
|
97
|
-
let routerRoute = null as RouterRoute | null;
|
|
98
|
-
const routeParams = new Map<string, ts.Type>();
|
|
99
|
-
|
|
100
|
-
function visitJSDocTags(tag: ts.JSDoc | ts.JSDocTag) {
|
|
101
|
-
if (ts.isJSDoc(tag)) {
|
|
102
|
-
tag.tags?.forEach(visitJSDocTags);
|
|
103
|
-
} else if (ts.isJSDocParameterTag(tag)) {
|
|
104
|
-
const typeNode = tag.typeExpression?.type;
|
|
105
|
-
const tc = project.getTypeChecker().compilerObject;
|
|
106
|
-
|
|
107
|
-
const type =
|
|
108
|
-
typeNode == null
|
|
109
|
-
? tc.getUnknownType()
|
|
110
|
-
: tc.getTypeFromTypeNode(typeNode);
|
|
111
|
-
|
|
112
|
-
routeParams.set(tag.name.getText(), type);
|
|
113
|
-
} else if (typeof tag.comment === 'string') {
|
|
114
|
-
switch (tag.tagName.getText()) {
|
|
115
|
-
case 'route': {
|
|
116
|
-
routerRoute = {
|
|
117
|
-
routeName: tag.comment,
|
|
118
|
-
sourceFile,
|
|
119
|
-
symbol,
|
|
120
|
-
params: routeParams,
|
|
121
|
-
};
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
case 'resource': {
|
|
125
|
-
routerResource = {
|
|
126
|
-
resourceName: tag.comment,
|
|
127
|
-
sourceFile,
|
|
128
|
-
symbol,
|
|
129
|
-
};
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
symbol
|
|
137
|
-
.getDeclarations()
|
|
138
|
-
.flatMap((decl) => ts.getJSDocCommentsAndTags(decl.compilerNode))
|
|
139
|
-
.forEach(visitJSDocTags);
|
|
140
|
-
|
|
141
|
-
if (routerRoute != null) routes.push(routerRoute);
|
|
142
|
-
if (routerResource != null) resources.push(routerResource);
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
project.getSourceFiles().forEach(visitRouterNodes);
|
|
147
|
-
return {resources, routes} as const;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function zodSchemaOfType(tc: ts.TypeChecker, t: ts.Type): string {
|
|
151
|
-
if (t.getFlags() & TypeFlags.String) {
|
|
152
|
-
return `z.pipe(z.string(), z.transform(decodeURIComponent))`;
|
|
153
|
-
} else if (t.getFlags() & TypeFlags.Number) {
|
|
154
|
-
return `z.coerce.number<number>()`;
|
|
155
|
-
} else if (t.getFlags() & TypeFlags.Null) {
|
|
156
|
-
return `z.preprocess(s => s == null ? undefined : s, z.undefined())`;
|
|
157
|
-
} else if (t.isUnion()) {
|
|
158
|
-
const isRepresentingOptional =
|
|
159
|
-
t.types.length === 2 &&
|
|
160
|
-
t.types.some((s) => s.getFlags() & TypeFlags.Null);
|
|
161
|
-
|
|
162
|
-
if (isRepresentingOptional) {
|
|
163
|
-
const nonOptionalType = t.types.find(
|
|
164
|
-
(s) => !(s.getFlags() & TypeFlags.Null),
|
|
165
|
-
)!;
|
|
166
|
-
|
|
167
|
-
return `z.pipe(z.nullish(${zodSchemaOfType(tc, nonOptionalType)}), z.transform(s => s == null ? undefined : s))`;
|
|
168
|
-
} else {
|
|
169
|
-
return `z.union([${t.types.map((it) => zodSchemaOfType(tc, it)).join(', ')}])`;
|
|
170
|
-
}
|
|
171
|
-
} else if (tc.isArrayLikeType(t)) {
|
|
172
|
-
const typeArg = tc.getTypeArguments(t as ts.TypeReference)[0];
|
|
173
|
-
const argZodSchema =
|
|
174
|
-
typeArg == null ? `z.any()` : zodSchemaOfType(tc, typeArg);
|
|
175
|
-
|
|
176
|
-
return `z.array(${argZodSchema})`;
|
|
177
|
-
} else {
|
|
178
|
-
console.log('Could not handle type:', tc.typeToString(t));
|
|
179
|
-
return `z.any()`;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function main() {
|
|
184
|
-
const targetDir = path.resolve(process.argv[2] || process.cwd());
|
|
185
|
-
process.chdir(targetDir);
|
|
186
|
-
const project = new Project({
|
|
187
|
-
tsConfigFilePath: path.join(targetDir, 'tsconfig.json'),
|
|
188
|
-
});
|
|
189
|
-
const tc = project.getTypeChecker().compilerObject;
|
|
190
|
-
|
|
191
|
-
const routerFiles = await loadRouterFiles(project);
|
|
192
|
-
const routerNodes = collectRouterNodes(project);
|
|
193
|
-
|
|
194
|
-
const resourceConf = routerFiles.jsResource
|
|
195
|
-
.getVariableDeclarationOrThrow('RESOURCE_CONF')
|
|
196
|
-
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
197
|
-
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
198
|
-
|
|
199
|
-
resourceConf.getPropertyOrThrow('noop').remove();
|
|
200
|
-
for (const {resourceName, sourceFile, symbol} of routerNodes.resources) {
|
|
201
|
-
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
202
|
-
const moduleSpecifier =
|
|
203
|
-
routerFiles.jsResource.getRelativePathAsModuleSpecifierTo(
|
|
204
|
-
sourceFile.getFilePath(),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
resourceConf.addPropertyAssignment({
|
|
208
|
-
name: `"${resourceName}"`,
|
|
209
|
-
initializer: (writer) => {
|
|
210
|
-
writer.block(() => {
|
|
211
|
-
writer
|
|
212
|
-
.writeLine(`src: "${filePath}",`)
|
|
213
|
-
.writeLine(
|
|
214
|
-
`loader: () => import("${moduleSpecifier}").then(m => m.${symbol.getName()})`,
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
console.log(
|
|
221
|
-
'Created resource',
|
|
222
|
-
pc.cyan(resourceName),
|
|
223
|
-
'for',
|
|
224
|
-
pc.green(symbol.getName()),
|
|
225
|
-
'exported from',
|
|
226
|
-
pc.yellow(filePath),
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const routerConf = routerFiles.router
|
|
231
|
-
.getVariableDeclarationOrThrow('ROUTER_CONF')
|
|
232
|
-
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
233
|
-
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
234
|
-
|
|
235
|
-
routerConf.getPropertyOrThrow('noop').remove();
|
|
236
|
-
|
|
237
|
-
let entryPointImportIndex = 0;
|
|
238
|
-
for (const {routeName, sourceFile, symbol, params} of routerNodes.routes) {
|
|
239
|
-
const importAlias = `e${entryPointImportIndex++}`;
|
|
240
|
-
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
241
|
-
const moduleSpecifier =
|
|
242
|
-
routerFiles.router.getRelativePathAsModuleSpecifierTo(
|
|
243
|
-
sourceFile.getFilePath(),
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
routerFiles.router.addImportDeclaration({
|
|
247
|
-
moduleSpecifier,
|
|
248
|
-
namedImports: [
|
|
249
|
-
{
|
|
250
|
-
name: symbol.getName(),
|
|
251
|
-
alias: importAlias,
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
routerConf.addPropertyAssignment({
|
|
257
|
-
name: `"${routeName}"`,
|
|
258
|
-
initializer: (writer) => {
|
|
259
|
-
writer
|
|
260
|
-
.write('{')
|
|
261
|
-
.indent(() => {
|
|
262
|
-
writer.writeLine(`entrypoint: ${importAlias},`);
|
|
263
|
-
if (params.size === 0) {
|
|
264
|
-
writer.writeLine(`schema: z.object({})`);
|
|
265
|
-
} else {
|
|
266
|
-
writer.writeLine(`schema: z.object({`);
|
|
267
|
-
for (const [paramName, paramType] of Array.from(params)) {
|
|
268
|
-
writer.writeLine(
|
|
269
|
-
` ${paramName}: ${zodSchemaOfType(tc, paramType)},`,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
writer.writeLine('})');
|
|
274
|
-
}
|
|
275
|
-
})
|
|
276
|
-
.write('} as const');
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
console.log(
|
|
281
|
-
'Created route',
|
|
282
|
-
pc.cyan(routeName),
|
|
283
|
-
'for',
|
|
284
|
-
pc.green(symbol.getName()),
|
|
285
|
-
'exported from',
|
|
286
|
-
pc.yellow(filePath),
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
await Promise.all([
|
|
291
|
-
routerFiles.jsResource.save(),
|
|
292
|
-
routerFiles.router.save(),
|
|
293
|
-
routerFiles.serverRouter.save(),
|
|
294
|
-
]);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
main().catch(console.error);
|
|
3
|
+
import {program} from 'commander';
|
|
4
|
+
import packageData from '../package.json' with {type: 'json'};
|
|
5
|
+
import {generatePastoriaArtifacts} from './generate.mts';
|
|
6
|
+
import {startDevserver} from './devserver.mts';
|
|
7
|
+
import {createBuild} from './build.mts';
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('pastoria')
|
|
11
|
+
.description(packageData.description)
|
|
12
|
+
.version(packageData.version);
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command('gen')
|
|
16
|
+
.description('Run Pastoria code generation')
|
|
17
|
+
.action(generatePastoriaArtifacts);
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('dev')
|
|
21
|
+
.description('Start the pastoria devserver')
|
|
22
|
+
.option('--port <port>', 'Port the devserver will listen on', '3000')
|
|
23
|
+
.action(startDevserver);
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.command('build')
|
|
27
|
+
.description('Creates a production build of the project')
|
|
28
|
+
.action(createBuild);
|
|
29
|
+
|
|
30
|
+
program.parseAsync();
|
package/templates/router.tsx
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnyPreloadedEntryPoint,
|
|
3
|
+
EnvironmentProvider,
|
|
4
|
+
relayClientEnvironment,
|
|
5
|
+
RouterOps,
|
|
6
|
+
} from 'pastoria-runtime';
|
|
1
7
|
import {createRouter} from 'radix3';
|
|
2
8
|
import {
|
|
3
9
|
AnchorHTMLAttributes,
|
|
4
10
|
createContext,
|
|
5
11
|
PropsWithChildren,
|
|
12
|
+
StrictMode,
|
|
6
13
|
Suspense,
|
|
7
14
|
useCallback,
|
|
8
15
|
useContext,
|
|
@@ -10,22 +17,16 @@ import {
|
|
|
10
17
|
useMemo,
|
|
11
18
|
useState,
|
|
12
19
|
} from 'react';
|
|
20
|
+
import {preinit, preloadModule} from 'react-dom';
|
|
13
21
|
import {
|
|
14
22
|
EntryPoint,
|
|
15
23
|
EntryPointContainer,
|
|
16
|
-
EnvironmentProviderOptions,
|
|
17
|
-
IEnvironmentProvider,
|
|
18
24
|
loadEntryPoint,
|
|
19
|
-
|
|
25
|
+
RelayEnvironmentProvider,
|
|
20
26
|
useEntryPointLoader,
|
|
21
27
|
} from 'react-relay/hooks';
|
|
22
|
-
import {OperationDescriptor, PayloadData} from 'relay-runtime';
|
|
23
|
-
import type {Manifest} from 'vite';
|
|
24
28
|
import * as z from 'zod/v4-mini';
|
|
25
29
|
|
|
26
|
-
export type AnyPreloadedEntryPoint = PreloadedEntryPoint<any>;
|
|
27
|
-
export type RouterOps = [OperationDescriptor, PayloadData][];
|
|
28
|
-
|
|
29
30
|
type RouterConf = typeof ROUTER_CONF;
|
|
30
31
|
const ROUTER_CONF = {
|
|
31
32
|
noop: {
|
|
@@ -121,9 +122,7 @@ function useLocation(initialPath?: string) {
|
|
|
121
122
|
return [location, setLocation] as const;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
export function router__hydrateStore(
|
|
125
|
-
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
126
|
-
) {
|
|
125
|
+
export function router__hydrateStore(provider: EnvironmentProvider) {
|
|
127
126
|
const env = provider.getEnvironment(null);
|
|
128
127
|
if ('__router_ops' in window) {
|
|
129
128
|
const ops = (window as any).__router_ops as RouterOps;
|
|
@@ -134,7 +133,7 @@ export function router__hydrateStore(
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
export async function router__loadEntryPoint(
|
|
137
|
-
provider:
|
|
136
|
+
provider: EnvironmentProvider,
|
|
138
137
|
initialPath?: string,
|
|
139
138
|
) {
|
|
140
139
|
if (!initialPath) initialPath = window.location.href;
|
|
@@ -159,12 +158,63 @@ const RouterContext = createContext<RouterContextValue>({
|
|
|
159
158
|
setLocation: () => {},
|
|
160
159
|
});
|
|
161
160
|
|
|
161
|
+
const REACT_REFRESH_SCRIPT = `
|
|
162
|
+
import RefreshRuntime from 'http://localhost:3000/@react-refresh'
|
|
163
|
+
RefreshRuntime.injectIntoGlobalHook(window)
|
|
164
|
+
window.$RefreshReg$ = () => {}
|
|
165
|
+
window.$RefreshSig$ = () => (type) => type
|
|
166
|
+
window.__vite_plugin_react_preamble_installed__ = true`;
|
|
167
|
+
|
|
162
168
|
export function router__createAppFromEntryPoint(
|
|
163
|
-
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
164
169
|
initialEntryPoint: AnyPreloadedEntryPoint | null,
|
|
170
|
+
provider: EnvironmentProvider,
|
|
165
171
|
initialPath?: string,
|
|
166
172
|
) {
|
|
167
|
-
|
|
173
|
+
const env = provider.getEnvironment(null);
|
|
174
|
+
|
|
175
|
+
function RouterShell({
|
|
176
|
+
preloadModules,
|
|
177
|
+
preloadStylesheets,
|
|
178
|
+
children,
|
|
179
|
+
}: PropsWithChildren<{
|
|
180
|
+
preloadModules?: string[];
|
|
181
|
+
preloadStylesheets?: string[];
|
|
182
|
+
}>) {
|
|
183
|
+
for (const m of preloadModules ?? []) {
|
|
184
|
+
preloadModule(m, {as: 'script'});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const s of preloadStylesheets ?? []) {
|
|
188
|
+
preinit(s, {as: 'style'});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<StrictMode>
|
|
193
|
+
<RelayEnvironmentProvider environment={env}>
|
|
194
|
+
<html>
|
|
195
|
+
<head>
|
|
196
|
+
<meta charSet="utf-8" />
|
|
197
|
+
<meta
|
|
198
|
+
name="viewport"
|
|
199
|
+
content="width=device-width, initial-scale=1"
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
{process.env.NODE_ENV !== 'production' && (
|
|
203
|
+
<script
|
|
204
|
+
type="module"
|
|
205
|
+
dangerouslySetInnerHTML={{__html: REACT_REFRESH_SCRIPT}}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</head>
|
|
209
|
+
|
|
210
|
+
<body>{children}</body>
|
|
211
|
+
</html>
|
|
212
|
+
</RelayEnvironmentProvider>
|
|
213
|
+
</StrictMode>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function RouterCore() {
|
|
168
218
|
const [location, setLocation] = useLocation(initialPath);
|
|
169
219
|
const routerContextValue = useMemo(
|
|
170
220
|
(): RouterContextValue => ({
|
|
@@ -213,16 +263,34 @@ export function router__createAppFromEntryPoint(
|
|
|
213
263
|
);
|
|
214
264
|
}
|
|
215
265
|
|
|
216
|
-
RouterApp
|
|
266
|
+
function RouterApp(props: {
|
|
267
|
+
preloadModules?: string[];
|
|
268
|
+
preloadStylesheets?: string[];
|
|
269
|
+
App?: React.ComponentType<PropsWithChildren<{}>> | null;
|
|
270
|
+
}) {
|
|
271
|
+
return (
|
|
272
|
+
<RouterShell
|
|
273
|
+
preloadModules={props.preloadModules}
|
|
274
|
+
preloadStylesheets={props.preloadStylesheets}
|
|
275
|
+
>
|
|
276
|
+
{props.App == null ? (
|
|
277
|
+
<RouterCore />
|
|
278
|
+
) : (
|
|
279
|
+
<props.App children={<RouterCore />} />
|
|
280
|
+
)}
|
|
281
|
+
</RouterShell>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
217
285
|
return RouterApp;
|
|
218
286
|
}
|
|
219
287
|
|
|
220
|
-
export async function createRouterApp(
|
|
221
|
-
provider
|
|
222
|
-
|
|
288
|
+
export async function createRouterApp() {
|
|
289
|
+
const provider = relayClientEnvironment;
|
|
290
|
+
|
|
223
291
|
router__hydrateStore(provider);
|
|
224
292
|
const ep = await router__loadEntryPoint(provider);
|
|
225
|
-
return router__createAppFromEntryPoint(
|
|
293
|
+
return router__createAppFromEntryPoint(ep, provider);
|
|
226
294
|
}
|
|
227
295
|
|
|
228
296
|
export function usePath() {
|
package/tsconfig.json
CHANGED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
EnvironmentProviderOptions,
|
|
3
|
-
IEnvironmentProvider,
|
|
4
|
-
OperationDescriptor,
|
|
5
|
-
PreloadedQuery,
|
|
6
|
-
} from 'react-relay/hooks';
|
|
7
|
-
import {
|
|
8
|
-
createOperationDescriptor,
|
|
9
|
-
GraphQLResponse,
|
|
10
|
-
GraphQLSingularResponse,
|
|
11
|
-
OperationType,
|
|
12
|
-
PayloadData,
|
|
13
|
-
PreloadableQueryRegistry,
|
|
14
|
-
} from 'relay-runtime';
|
|
15
|
-
import serialize from 'serialize-javascript';
|
|
16
|
-
import type {Manifest} from 'vite';
|
|
17
|
-
import {JSResource} from './js_resource';
|
|
18
|
-
import {
|
|
19
|
-
AnyPreloadedEntryPoint,
|
|
20
|
-
router__createAppFromEntryPoint,
|
|
21
|
-
router__loadEntryPoint,
|
|
22
|
-
RouterOps,
|
|
23
|
-
} from './router';
|
|
24
|
-
|
|
25
|
-
type AnyPreloadedQuery = PreloadedQuery<OperationType>;
|
|
26
|
-
|
|
27
|
-
function router__bootstrapScripts(
|
|
28
|
-
entryPoint: AnyPreloadedEntryPoint,
|
|
29
|
-
ops: RouterOps,
|
|
30
|
-
manifest?: Manifest,
|
|
31
|
-
) {
|
|
32
|
-
let bootstrap = `
|
|
33
|
-
<script type="text/javascript">
|
|
34
|
-
window.__router_ops = ${serialize(ops)};
|
|
35
|
-
</script>`;
|
|
36
|
-
|
|
37
|
-
const rootModuleSrc = JSResource.srcOfModuleId(entryPoint.rootModuleID);
|
|
38
|
-
if (rootModuleSrc == null) return bootstrap;
|
|
39
|
-
|
|
40
|
-
function crawlImports(moduleName: string) {
|
|
41
|
-
const chunk = manifest?.[moduleName];
|
|
42
|
-
if (!chunk) return;
|
|
43
|
-
|
|
44
|
-
chunk.imports?.forEach(crawlImports);
|
|
45
|
-
bootstrap =
|
|
46
|
-
`<link rel="modulepreload" href="${chunk.file}" />\n` + bootstrap;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
crawlImports(rootModuleSrc);
|
|
50
|
-
return bootstrap;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function router__ensureQueryFlushed(
|
|
54
|
-
query: AnyPreloadedQuery,
|
|
55
|
-
): Promise<GraphQLResponse> {
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
if (query.source == null) {
|
|
58
|
-
resolve({data: {}});
|
|
59
|
-
} else {
|
|
60
|
-
query.source.subscribe({
|
|
61
|
-
next: resolve,
|
|
62
|
-
error: reject,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function router__loadQueries(entryPoint: AnyPreloadedEntryPoint) {
|
|
69
|
-
const preloadedQueryOps: [OperationDescriptor, PayloadData][] = [];
|
|
70
|
-
for (const query of Object.values(
|
|
71
|
-
entryPoint?.queries ?? {},
|
|
72
|
-
) as PreloadedQuery<OperationType>[]) {
|
|
73
|
-
try {
|
|
74
|
-
const payload = await router__ensureQueryFlushed(query);
|
|
75
|
-
const concreteRequest =
|
|
76
|
-
query.id == null ? null : PreloadableQueryRegistry.get(query.id);
|
|
77
|
-
|
|
78
|
-
if (concreteRequest != null) {
|
|
79
|
-
const desc = createOperationDescriptor(
|
|
80
|
-
concreteRequest,
|
|
81
|
-
query.variables,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
preloadedQueryOps.push([
|
|
85
|
-
desc,
|
|
86
|
-
(payload as GraphQLSingularResponse).data!,
|
|
87
|
-
]);
|
|
88
|
-
}
|
|
89
|
-
} catch (e) {
|
|
90
|
-
console.error(e);
|
|
91
|
-
throw e;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return preloadedQueryOps;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function createRouterServerApp(
|
|
99
|
-
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
100
|
-
initialPath: string,
|
|
101
|
-
) {
|
|
102
|
-
const ep = await router__loadEntryPoint(provider, initialPath);
|
|
103
|
-
const ops = ep != null ? await router__loadQueries(ep) : [];
|
|
104
|
-
const RouterApp = router__createAppFromEntryPoint(provider, ep, initialPath);
|
|
105
|
-
|
|
106
|
-
if (ep != null) {
|
|
107
|
-
RouterApp.bootstrap = (manifest) =>
|
|
108
|
-
router__bootstrapScripts(ep, ops, manifest);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return RouterApp;
|
|
112
|
-
}
|