@webstir-io/module-contract 0.3.1 → 0.5.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/dist/page-routes.d.ts +30 -0
- package/dist/page-routes.js +176 -0
- package/dist/render-execute.d.ts +1 -1
- package/dist/render-execute.js +1 -1
- package/dist/render-program.d.ts +1 -2
- package/dist/render-program.js +2 -2
- package/dist/render-version.d.ts +2 -0
- package/dist/render-version.js +2 -0
- package/dist/workspace.d.ts +19 -0
- package/dist/workspace.js +34 -0
- package/package.json +16 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A view that routes a dynamic path pattern to a built frontend page. */
|
|
2
|
+
export interface PageRoute {
|
|
3
|
+
readonly page: string;
|
|
4
|
+
readonly pattern: string;
|
|
5
|
+
readonly name?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PageRouteMatch {
|
|
8
|
+
readonly route: PageRoute;
|
|
9
|
+
readonly params: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Reads the page views declared in `package.json` under `webstir.moduleManifest.views`.
|
|
13
|
+
* A view routes to a frontend page when it names a `page` and its `renderMode` is `spa`
|
|
14
|
+
* or omitted. Views without a `page` are left to the backend runtime.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readWorkspacePageRoutes(workspaceRoot: string): Promise<readonly PageRoute[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Validates view declarations and orders them the way Bun's router ranks overlapping
|
|
19
|
+
* patterns: segment by segment from the left, a static segment beats a parameter at the
|
|
20
|
+
* first position where two patterns differ. Declaration order breaks exact ties.
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizePageRoutes(views: readonly unknown[]): readonly PageRoute[];
|
|
23
|
+
export declare function normalizePagePattern(pattern: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* A `:param` matches any single path segment, including one containing a dot, exactly as
|
|
26
|
+
* Bun's router does in watch. Static files are always resolved before patterns are tried.
|
|
27
|
+
*/
|
|
28
|
+
export declare function matchPageRoute(routes: readonly PageRoute[], pathname: string): PageRouteMatch | undefined;
|
|
29
|
+
/** Negative when `left` should be tried before `right`. Mirrors Bun.serve route ranking. */
|
|
30
|
+
export declare function compareSpecificity(left: string, right: string): number;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
const PARAM_SEGMENT = /^:([A-Za-z0-9_]+)$/;
|
|
4
|
+
const STATIC_SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
5
|
+
/**
|
|
6
|
+
* Reads the page views declared in `package.json` under `webstir.moduleManifest.views`.
|
|
7
|
+
* A view routes to a frontend page when it names a `page` and its `renderMode` is `spa`
|
|
8
|
+
* or omitted. Views without a `page` are left to the backend runtime.
|
|
9
|
+
*/
|
|
10
|
+
export async function readWorkspacePageRoutes(workspaceRoot) {
|
|
11
|
+
const source = await readFile(path.join(workspaceRoot, 'package.json'), 'utf8');
|
|
12
|
+
const packageJson = JSON.parse(source);
|
|
13
|
+
const views = packageJson.webstir?.moduleManifest?.views;
|
|
14
|
+
return normalizePageRoutes(Array.isArray(views) ? views : []);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validates view declarations and orders them the way Bun's router ranks overlapping
|
|
18
|
+
* patterns: segment by segment from the left, a static segment beats a parameter at the
|
|
19
|
+
* first position where two patterns differ. Declaration order breaks exact ties.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePageRoutes(views) {
|
|
22
|
+
const routes = [];
|
|
23
|
+
const owners = new Map();
|
|
24
|
+
for (const view of views) {
|
|
25
|
+
if (!view || typeof view !== 'object') {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const { name, path: pattern, page, renderMode } = view;
|
|
29
|
+
if (page === undefined) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (typeof page !== 'string' || page.length === 0) {
|
|
33
|
+
throw new Error('[webstir] a view "page" must be a non-empty page name.');
|
|
34
|
+
}
|
|
35
|
+
// The name becomes a path and an address (pages/<name>, /<name>/), so it is one.
|
|
36
|
+
if (!page
|
|
37
|
+
.split('/')
|
|
38
|
+
.every((segment) => STATIC_SEGMENT.test(segment) && segment !== '.' && segment !== '..')) {
|
|
39
|
+
throw new Error(`[webstir] view page "${page}" is not a page name; use the page's folder under src/frontend/pages, such as "items" or "docs/intro".`);
|
|
40
|
+
}
|
|
41
|
+
if (renderMode !== undefined && renderMode !== 'spa') {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (typeof pattern !== 'string') {
|
|
45
|
+
throw new Error(`[webstir] the view for page "${page}" needs a "path" pattern.`);
|
|
46
|
+
}
|
|
47
|
+
const normalized = normalizePagePattern(pattern);
|
|
48
|
+
const shape = patternShape(normalized);
|
|
49
|
+
const owner = owners.get(shape);
|
|
50
|
+
if (owner && owner !== page) {
|
|
51
|
+
throw new Error(`[webstir] view path ${normalized} is declared by both "${owner}" and "${page}".`);
|
|
52
|
+
}
|
|
53
|
+
owners.set(shape, page);
|
|
54
|
+
routes.push({
|
|
55
|
+
page,
|
|
56
|
+
pattern: normalized,
|
|
57
|
+
...(typeof name === 'string' && name.length > 0 ? { name } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return sortBySpecificity(routes);
|
|
61
|
+
}
|
|
62
|
+
export function normalizePagePattern(pattern) {
|
|
63
|
+
const trimmed = pattern.trim();
|
|
64
|
+
if (!trimmed.startsWith('/')) {
|
|
65
|
+
throw new Error(`[webstir] view path "${pattern}" must start with "/".`);
|
|
66
|
+
}
|
|
67
|
+
const segments = trimmed.split('/').filter((segment) => segment.length > 0);
|
|
68
|
+
if (segments.length === 0) {
|
|
69
|
+
throw new Error('[webstir] view path "/" cannot route to a page; use the home page instead.');
|
|
70
|
+
}
|
|
71
|
+
const lastSegment = segments[segments.length - 1];
|
|
72
|
+
if (!PARAM_SEGMENT.test(lastSegment) && path.posix.extname(lastSegment) !== '') {
|
|
73
|
+
throw new Error(`[webstir] view path "${pattern}" ends in a file name ("${lastSegment}"). Page views serve HTML documents; use a static file or a route for files.`);
|
|
74
|
+
}
|
|
75
|
+
const params = new Set();
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
const param = PARAM_SEGMENT.exec(segment);
|
|
78
|
+
if (param) {
|
|
79
|
+
if (params.has(param[1])) {
|
|
80
|
+
throw new Error(`[webstir] view path "${pattern}" repeats the parameter ":${param[1]}".`);
|
|
81
|
+
}
|
|
82
|
+
params.add(param[1]);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!STATIC_SEGMENT.test(segment)) {
|
|
86
|
+
throw new Error(`[webstir] view path "${pattern}" has an invalid segment "${segment}". Use static segments or ":param".`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `/${segments.join('/')}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A `:param` matches any single path segment, including one containing a dot, exactly as
|
|
93
|
+
* Bun's router does in watch. Static files are always resolved before patterns are tried.
|
|
94
|
+
*/
|
|
95
|
+
export function matchPageRoute(routes, pathname) {
|
|
96
|
+
const segments = splitRequestPath(pathname);
|
|
97
|
+
if (!segments) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
for (const route of routes) {
|
|
101
|
+
const params = matchPattern(route.pattern, segments);
|
|
102
|
+
if (params) {
|
|
103
|
+
return { route, params };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
function matchPattern(pattern, segments) {
|
|
109
|
+
const expectedSegments = pattern.split('/').filter((segment) => segment.length > 0);
|
|
110
|
+
if (expectedSegments.length !== segments.length) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const params = {};
|
|
114
|
+
for (let index = 0; index < expectedSegments.length; index += 1) {
|
|
115
|
+
const expected = expectedSegments[index];
|
|
116
|
+
const actual = segments[index];
|
|
117
|
+
const param = PARAM_SEGMENT.exec(expected);
|
|
118
|
+
if (param) {
|
|
119
|
+
params[param[1]] = actual;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (expected !== actual) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return params;
|
|
127
|
+
}
|
|
128
|
+
function splitRequestPath(pathname) {
|
|
129
|
+
const segments = [];
|
|
130
|
+
for (const raw of pathname.split('/')) {
|
|
131
|
+
if (raw.length === 0) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
let decoded;
|
|
135
|
+
try {
|
|
136
|
+
decoded = decodeURIComponent(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/')) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
segments.push(decoded);
|
|
145
|
+
}
|
|
146
|
+
return segments.length > 0 ? segments : undefined;
|
|
147
|
+
}
|
|
148
|
+
function sortBySpecificity(routes) {
|
|
149
|
+
return routes
|
|
150
|
+
.map((route, index) => ({ route, index }))
|
|
151
|
+
.sort((left, right) => compareSpecificity(left.route.pattern, right.route.pattern) || left.index - right.index)
|
|
152
|
+
.map((entry) => entry.route);
|
|
153
|
+
}
|
|
154
|
+
/** Negative when `left` should be tried before `right`. Mirrors Bun.serve route ranking. */
|
|
155
|
+
export function compareSpecificity(left, right) {
|
|
156
|
+
const leftSegments = left.split('/').filter((segment) => segment.length > 0);
|
|
157
|
+
const rightSegments = right.split('/').filter((segment) => segment.length > 0);
|
|
158
|
+
if (leftSegments.length !== rightSegments.length) {
|
|
159
|
+
return leftSegments.length - rightSegments.length;
|
|
160
|
+
}
|
|
161
|
+
for (let index = 0; index < leftSegments.length; index += 1) {
|
|
162
|
+
const leftIsParam = PARAM_SEGMENT.test(leftSegments[index]);
|
|
163
|
+
const rightIsParam = PARAM_SEGMENT.test(rightSegments[index]);
|
|
164
|
+
if (leftIsParam !== rightIsParam) {
|
|
165
|
+
return leftIsParam ? 1 : -1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
/** Two patterns that differ only in parameter names match the same paths. */
|
|
171
|
+
function patternShape(pattern) {
|
|
172
|
+
return pattern
|
|
173
|
+
.split('/')
|
|
174
|
+
.map((segment) => (PARAM_SEGMENT.test(segment) ? ':' : segment))
|
|
175
|
+
.join('/');
|
|
176
|
+
}
|
package/dist/render-execute.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RenderProgram, RenderSourceLocation } from './render-program.js';
|
|
2
2
|
export declare const RENDER_CSRF_FIELD = "_csrf";
|
|
3
3
|
export declare class RenderProgramError extends Error {
|
|
4
4
|
readonly loc: RenderSourceLocation;
|
package/dist/render-execute.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RENDER_PROGRAM_VERSION
|
|
1
|
+
import { RENDER_PROGRAM_VERSION } from './render-version.js';
|
|
2
2
|
export const RENDER_CSRF_FIELD = '_csrf';
|
|
3
3
|
const SAFE_URL_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
4
4
|
const BLOCKED_URL = 'about:invalid';
|
package/dist/render-program.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
export
|
|
3
|
-
export declare const RENDER_PROGRAM_FILE = "index.program.json";
|
|
2
|
+
export { RENDER_PROGRAM_FILE, RENDER_PROGRAM_VERSION } from './render-version.js';
|
|
4
3
|
export declare const renderSourceLocationSchema: z.ZodObject<{
|
|
5
4
|
file: z.ZodString;
|
|
6
5
|
line: z.ZodNumber;
|
package/dist/render-program.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { RENDER_PROGRAM_VERSION } from './render-version.js';
|
|
3
|
+
export { RENDER_PROGRAM_FILE, RENDER_PROGRAM_VERSION } from './render-version.js';
|
|
4
4
|
export const renderSourceLocationSchema = z.object({
|
|
5
5
|
file: z.string().min(1),
|
|
6
6
|
line: z.number().int().positive(),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What an app is made of, read from its files rather than declared. */
|
|
2
|
+
export interface WorkspaceLayers {
|
|
3
|
+
/** The app has pages: a `src/frontend/` directory. */
|
|
4
|
+
readonly pages: boolean;
|
|
5
|
+
/** The app runs a server: its HTTP entry, `src/backend/index.{ts,tsx,js,mjs}`. */
|
|
6
|
+
readonly server: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function readWorkspaceLayers(workspaceRoot: string): WorkspaceLayers;
|
|
9
|
+
/**
|
|
10
|
+
* Pages without a server publish as files any static host serves, and their views render at
|
|
11
|
+
* publish.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isStaticApp(layers: WorkspaceLayers): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the app has backend code to compile: its server entry, or a function's or job's own
|
|
16
|
+
* entry (`src/backend/functions/<name>/index.*`, `src/backend/jobs/<name>/index.*`). Functions and
|
|
17
|
+
* jobs compile without a server, but are not one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasBackendEntries(workspaceRoot: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const SERVER_ENTRIES = ['index.ts', 'index.tsx', 'index.js', 'index.mjs'];
|
|
4
|
+
export function readWorkspaceLayers(workspaceRoot) {
|
|
5
|
+
const root = path.resolve(workspaceRoot);
|
|
6
|
+
return {
|
|
7
|
+
pages: existsSync(path.join(root, 'src', 'frontend')),
|
|
8
|
+
server: SERVER_ENTRIES.some((entry) => existsSync(path.join(root, 'src', 'backend', entry))),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pages without a server publish as files any static host serves, and their views render at
|
|
13
|
+
* publish.
|
|
14
|
+
*/
|
|
15
|
+
export function isStaticApp(layers) {
|
|
16
|
+
return layers.pages && !layers.server;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether the app has backend code to compile: its server entry, or a function's or job's own
|
|
20
|
+
* entry (`src/backend/functions/<name>/index.*`, `src/backend/jobs/<name>/index.*`). Functions and
|
|
21
|
+
* jobs compile without a server, but are not one.
|
|
22
|
+
*/
|
|
23
|
+
export function hasBackendEntries(workspaceRoot) {
|
|
24
|
+
const backendRoot = path.join(path.resolve(workspaceRoot), 'src', 'backend');
|
|
25
|
+
const hasIndex = (directory) => SERVER_ENTRIES.some((entry) => existsSync(path.join(directory, entry)));
|
|
26
|
+
if (hasIndex(backendRoot))
|
|
27
|
+
return true;
|
|
28
|
+
return ['functions', 'jobs'].some((group) => {
|
|
29
|
+
const groupRoot = path.join(backendRoot, group);
|
|
30
|
+
if (!existsSync(groupRoot))
|
|
31
|
+
return false;
|
|
32
|
+
return readdirSync(groupRoot, { withFileTypes: true }).some((entry) => entry.isDirectory() && hasIndex(path.join(groupRoot, entry.name)));
|
|
33
|
+
});
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webstir-io/module-contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Public contract definitions for Webstir frontend and backend module providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,6 +16,21 @@
|
|
|
16
16
|
"import": "./dist/client-nav.js",
|
|
17
17
|
"default": "./dist/client-nav.js"
|
|
18
18
|
},
|
|
19
|
+
"./render": {
|
|
20
|
+
"types": "./dist/render-execute.d.ts",
|
|
21
|
+
"import": "./dist/render-execute.js",
|
|
22
|
+
"default": "./dist/render-execute.js"
|
|
23
|
+
},
|
|
24
|
+
"./page-routes": {
|
|
25
|
+
"types": "./dist/page-routes.d.ts",
|
|
26
|
+
"import": "./dist/page-routes.js",
|
|
27
|
+
"default": "./dist/page-routes.js"
|
|
28
|
+
},
|
|
29
|
+
"./workspace": {
|
|
30
|
+
"types": "./dist/workspace.d.ts",
|
|
31
|
+
"import": "./dist/workspace.js",
|
|
32
|
+
"default": "./dist/workspace.js"
|
|
33
|
+
},
|
|
19
34
|
"./ts-rest": {
|
|
20
35
|
"types": "./dist/adapters/ts-rest.d.ts",
|
|
21
36
|
"import": "./dist/adapters/ts-rest.js",
|