@fluixi/start 0.1.0-alpha.43
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 +21 -0
- package/README.md +101 -0
- package/dist/adapter.d.ts +32 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +78 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +120 -0
- package/dist/commands/build.d.ts +8 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +67 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +91 -0
- package/dist/commands/prerender.d.ts +11 -0
- package/dist/commands/prerender.d.ts.map +1 -0
- package/dist/commands/prerender.js +78 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +18 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +116 -0
- package/dist/di.d.ts +63 -0
- package/dist/di.d.ts.map +1 -0
- package/dist/di.js +210 -0
- package/dist/handler.d.ts +42 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +152 -0
- package/dist/head.d.ts +14 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +13 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/interceptors.d.ts +15 -0
- package/dist/interceptors.d.ts.map +1 -0
- package/dist/interceptors.js +47 -0
- package/dist/internal.d.ts +87 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +302 -0
- package/dist/middleware.d.ts +38 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +39 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +12 -0
- package/dist/server-fn-setup.d.ts +8 -0
- package/dist/server-fn-setup.d.ts.map +1 -0
- package/dist/server-fn-setup.js +17 -0
- package/dist/server-fn.d.ts +33 -0
- package/dist/server-fn.d.ts.map +1 -0
- package/dist/server-fn.js +96 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/ui.d.ts +35 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +99 -0
- package/package.json +86 -0
package/dist/internal.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve as resolvePath, relative as relativePath } from 'node:path';
|
|
3
|
+
import { extractHeadMarker } from '@fluixi/head';
|
|
4
|
+
/** The virtual module that registers every `"use server"` fn + re-exports the dispatcher. */
|
|
5
|
+
export const SERVER_FNS_VMOD = 'virtual:fluixi-server-fns';
|
|
6
|
+
/** The virtual module exposing auto-discovered translations: `{ messages, locales, defaultLocale }`. */
|
|
7
|
+
export const I18N_VMOD = 'virtual:fluixi-i18n';
|
|
8
|
+
/**
|
|
9
|
+
* Auto-discover translations. Scans `<dir>/<locale>.json` (e.g. `src/i18n/en.json`, `fr.json`)
|
|
10
|
+
* and provides `virtual:fluixi-i18n` exporting `{ messages, locales, defaultLocale }` — wire it
|
|
11
|
+
* into the built-in i18n in one line:
|
|
12
|
+
*
|
|
13
|
+
* import { createI18n } from '@fluixi/core/i18n';
|
|
14
|
+
* import { messages, locales, defaultLocale } from 'virtual:fluixi-i18n';
|
|
15
|
+
* export const { t, locale, setLocale } = createI18n({ locale: defaultLocale, locales, messages });
|
|
16
|
+
*
|
|
17
|
+
* Configured via `fluixi.config`'s `i18n` (folder / locales / defaultLocale); `i18n: false` disables it.
|
|
18
|
+
*/
|
|
19
|
+
export function i18nVitePlugin(opts) {
|
|
20
|
+
let root = process.cwd();
|
|
21
|
+
const id = '\0' + I18N_VMOD;
|
|
22
|
+
return {
|
|
23
|
+
name: 'fluixi-i18n',
|
|
24
|
+
configResolved(config) {
|
|
25
|
+
root = config.root || root;
|
|
26
|
+
},
|
|
27
|
+
resolveId(source) {
|
|
28
|
+
return source === I18N_VMOD ? id : null;
|
|
29
|
+
},
|
|
30
|
+
load(source) {
|
|
31
|
+
if (source !== id)
|
|
32
|
+
return null;
|
|
33
|
+
const dir = resolvePath(root, opts.dir);
|
|
34
|
+
let files = [];
|
|
35
|
+
try {
|
|
36
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* folder doesn't exist → empty bundle (i18n stays a no-op until you add files) */
|
|
40
|
+
}
|
|
41
|
+
const discovered = files.map((f) => f.replace(/\.json$/, '')).sort();
|
|
42
|
+
const locales = opts.locales?.length ? opts.locales : discovered;
|
|
43
|
+
const defaultLocale = opts.defaultLocale ?? locales[0] ?? 'en';
|
|
44
|
+
// Static JSON imports so Vite bundles + watches them; `messages` keyed by locale.
|
|
45
|
+
const imports = locales
|
|
46
|
+
.map((l, i) => `import _${i} from ${JSON.stringify(resolvePath(dir, l + '.json'))};`)
|
|
47
|
+
.join('\n');
|
|
48
|
+
const messages = `export const messages = {${locales.map((l, i) => `${JSON.stringify(l)}:_${i}`).join(',')}};`;
|
|
49
|
+
return (`${imports}\n` +
|
|
50
|
+
`export const locales = ${JSON.stringify(locales)};\n` +
|
|
51
|
+
`export const defaultLocale = ${JSON.stringify(defaultLocale)};\n` +
|
|
52
|
+
`${messages}\n`);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Load env files into `process.env` for SERVER code — the framework's environment manager.
|
|
58
|
+
*
|
|
59
|
+
* SECURITY MODEL (Vite-aligned): only `VITE_`-prefixed vars are exposed to the client (via
|
|
60
|
+
* `import.meta.env`, inlined into the browser bundle). Everything else is server-only. This
|
|
61
|
+
* loads ALL vars (`.env`, `.env.local`, `.env.[mode]`, `.env.[mode].local`) into `process.env`
|
|
62
|
+
* so loaders + `"use server"` functions can read secrets — WITHOUT ever exposing non-`VITE_`
|
|
63
|
+
* vars to the browser. A real (OS) env var always wins, so deploys/secret managers override files.
|
|
64
|
+
*
|
|
65
|
+
* Result: `process.env.STRIPE_SECRET` works on the server and never ships to the web; only
|
|
66
|
+
* `VITE_*` reaches the client. Apps stop hand-rolling dotenv loaders + risking secret leaks.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadServerEnv(root, mode) {
|
|
69
|
+
const { loadEnv } = await import('vite');
|
|
70
|
+
const env = loadEnv(mode, root, ''); // '' prefix = ALL vars (server-only via process.env)
|
|
71
|
+
for (const [k, v] of Object.entries(env)) {
|
|
72
|
+
if (process.env[k] === undefined)
|
|
73
|
+
process.env[k] = v; // real env wins
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the `declare module 'virtual:fluixi-i18n'` block — TYPED off the actual JSON files
|
|
78
|
+
* so `createI18n({ messages })` infers translation keys end-to-end from auto-discovery.
|
|
79
|
+
*
|
|
80
|
+
* Instead of typing `messages` as `Record<string, unknown>` (which erases keys), we reference
|
|
81
|
+
* each `<locale>.json` via `(typeof import('./i18n/en.json'))['default']`. TS reads the JSON's
|
|
82
|
+
* real shape, so `t('…')` autocompletes keys discovered from `src/i18n/*.json` — no codegen of
|
|
83
|
+
* the key union, the compiler derives it. Falls back to the loose shape when no files exist yet
|
|
84
|
+
* (or a configured locale has no file), keeping the module typed-but-open rather than broken.
|
|
85
|
+
*
|
|
86
|
+
* NOTE: relies on `resolveJsonModule` (Vite's default tsconfig has it).
|
|
87
|
+
*/
|
|
88
|
+
function i18nTypeBlock(root, i18n) {
|
|
89
|
+
const dir = resolvePath(root, i18n.dir);
|
|
90
|
+
let discovered = [];
|
|
91
|
+
try {
|
|
92
|
+
discovered = readdirSync(dir)
|
|
93
|
+
.filter((f) => f.endsWith('.json'))
|
|
94
|
+
.map((f) => f.replace(/\.json$/, ''))
|
|
95
|
+
.sort();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* folder absent → fall through to the loose block */
|
|
99
|
+
}
|
|
100
|
+
const locales = i18n.locales?.length ? i18n.locales : discovered;
|
|
101
|
+
const defaultLocale = i18n.defaultLocale ?? locales[0] ?? 'en';
|
|
102
|
+
// Typed only when every locale we'd export has a backing JSON file (else `import()` would error).
|
|
103
|
+
const typed = locales.length > 0 && locales.every((l) => discovered.includes(l));
|
|
104
|
+
if (!typed) {
|
|
105
|
+
return [
|
|
106
|
+
`declare module 'virtual:fluixi-i18n' {`,
|
|
107
|
+
` export const messages: Record<string, Record<string, unknown>>;`,
|
|
108
|
+
` export const locales: string[];`,
|
|
109
|
+
` export const defaultLocale: string;`,
|
|
110
|
+
`}`,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
const srcDir = resolvePath(root, 'src'); // the .d.ts lives in src/, so import paths are relative to it
|
|
114
|
+
const toSpec = (l) => {
|
|
115
|
+
const rel = relativePath(srcDir, resolvePath(dir, l + '.json')).split(/[\\/]/).join('/');
|
|
116
|
+
return rel.startsWith('.') ? rel : './' + rel;
|
|
117
|
+
};
|
|
118
|
+
const entries = locales.map((l) => ` ${JSON.stringify(l)}: (typeof import(${JSON.stringify(toSpec(l))}))['default'];`);
|
|
119
|
+
const tuple = locales.map((l) => JSON.stringify(l)).join(', ');
|
|
120
|
+
return [
|
|
121
|
+
`declare module 'virtual:fluixi-i18n' {`,
|
|
122
|
+
` export const messages: {`,
|
|
123
|
+
...entries,
|
|
124
|
+
` };`,
|
|
125
|
+
` export const locales: readonly [${tuple}];`,
|
|
126
|
+
` export const defaultLocale: ${JSON.stringify(defaultLocale)};`,
|
|
127
|
+
`}`,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Auto-generate ambient TypeScript declarations for @fluixi/start's virtual modules
|
|
132
|
+
* (`virtual:fluixi-server-fns`, `virtual:fluixi-i18n`) into `src/fluixi-start-env.d.ts`, the
|
|
133
|
+
* way the routes plugin writes `fluixi-env.d.ts`. So apps NEVER hand-write these `declare
|
|
134
|
+
* module` blocks — the modules AND their types are both generated. Idempotent (only writes on
|
|
135
|
+
* change), runs in dev + build.
|
|
136
|
+
*/
|
|
137
|
+
export function startTypesPlugin(cfg) {
|
|
138
|
+
let root = process.cwd();
|
|
139
|
+
return {
|
|
140
|
+
name: 'fluixi-start-types',
|
|
141
|
+
configResolved(config) {
|
|
142
|
+
root = config.root || root;
|
|
143
|
+
},
|
|
144
|
+
buildStart() {
|
|
145
|
+
const blocks = [
|
|
146
|
+
`declare module 'virtual:fluixi-server-fns' {`,
|
|
147
|
+
` export function isServerFnRequest(request: Request): boolean;`,
|
|
148
|
+
` export function handleServerFn(request: Request): Promise<Response>;`,
|
|
149
|
+
`}`,
|
|
150
|
+
`declare module 'virtual:fluixi-api' {`,
|
|
151
|
+
` export function isApiRequest(request: Request): boolean;`,
|
|
152
|
+
` export function handleApiRequest(request: Request): Promise<Response>;`,
|
|
153
|
+
`}`,
|
|
154
|
+
];
|
|
155
|
+
if (cfg.i18n)
|
|
156
|
+
blocks.push(...i18nTypeBlock(root, cfg.i18n));
|
|
157
|
+
const content = `// Auto-generated by @fluixi/start — do not edit.\n${blocks.join('\n')}\n`;
|
|
158
|
+
const file = resolvePath(root, 'src/fluixi-start-env.d.ts');
|
|
159
|
+
try {
|
|
160
|
+
if (!existsSync(file) || readFileSync(file, 'utf8') !== content)
|
|
161
|
+
writeFileSync(file, content);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* read-only fs (some CI) — types are a dev convenience, build still works */
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The fluixi build plugins @fluixi/start provides by default: file-based routing
|
|
171
|
+
* (fluixiRoutesPlugin → virtual:fluixi-routes) + the JSX/SSR transform
|
|
172
|
+
* (createVitePlugin). This is what makes start "sit on top of fluixi" — apps get
|
|
173
|
+
* zero-config routing + SSR. Apps using `fluixi dev|build` should therefore NOT add
|
|
174
|
+
* these to their own vite.config (start owns them); keep vite.config for app-specific
|
|
175
|
+
* bits only (optimizeDeps, ssr.noExternal via config, etc.).
|
|
176
|
+
*/
|
|
177
|
+
export async function fluixiPlugins(cfg) {
|
|
178
|
+
const { createVitePlugin, fluixiRoutesPlugin } = await import('@fluixi/core/plugins');
|
|
179
|
+
const { serverFunctionsVitePlugin } = await import('@fluixi/compiler/integrations');
|
|
180
|
+
// polyfills:false — the meta-framework is SSR-first; the browser node-polyfill shims
|
|
181
|
+
// break server rendering. Apps that need Buffer/process in the browser opt in later.
|
|
182
|
+
// serverFunctionsVitePlugin applies the "use server" transform (client RPC stub /
|
|
183
|
+
// server registration) per-build via the ssr flag.
|
|
184
|
+
const plugins = [
|
|
185
|
+
serverFunctionsVitePlugin(),
|
|
186
|
+
fluixiRoutesPlugin({ routesDir: cfg.routesDir, polyfills: false }),
|
|
187
|
+
createVitePlugin(),
|
|
188
|
+
];
|
|
189
|
+
// Auto-discover translations (src/i18n/<locale>.json → virtual:fluixi-i18n) unless disabled.
|
|
190
|
+
if (cfg.i18n)
|
|
191
|
+
plugins.push(i18nVitePlugin(cfg.i18n));
|
|
192
|
+
// File-based API routes (src/api/** → virtual:fluixi-api). No-op until the folder has files.
|
|
193
|
+
const { apiRoutesVitePlugin } = await import('./api.js');
|
|
194
|
+
plugins.push(apiRoutesVitePlugin({ dir: cfg.apiDir }));
|
|
195
|
+
// Auto-generate the ambient .d.ts for the virtual modules (server-fns + i18n).
|
|
196
|
+
plugins.push(startTypesPlugin(cfg));
|
|
197
|
+
return plugins;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Inject server-rendered app HTML into the template's mount element
|
|
201
|
+
* (`<div id="root"></div>`). Falls back to a `<!--ssr-outlet-->` marker, then to
|
|
202
|
+
* just before `</body>`.
|
|
203
|
+
*/
|
|
204
|
+
export function injectApp(template, appHtml, mountId = 'root') {
|
|
205
|
+
const mount = new RegExp(`<div id=["']${mountId}["']\\s*>\\s*</div>`);
|
|
206
|
+
if (mount.test(template)) {
|
|
207
|
+
return template.replace(mount, `<div id="${mountId}">${appHtml}</div>`);
|
|
208
|
+
}
|
|
209
|
+
if (template.includes('<!--ssr-outlet-->')) {
|
|
210
|
+
return template.replace('<!--ssr-outlet-->', appHtml);
|
|
211
|
+
}
|
|
212
|
+
return template.replace('</body>', `<div id="${mountId}">${appHtml}</div></body>`);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Split the template at the mount point for streaming: `head` is everything up to and
|
|
216
|
+
* including the open mount tag (flushed first so the browser fetches assets while the
|
|
217
|
+
* server awaits data); `tail` is the close tag onward (client script + `</body>`).
|
|
218
|
+
* Mirrors injectApp's three cases (mount div, `<!--ssr-outlet-->`, before `</body>`).
|
|
219
|
+
*/
|
|
220
|
+
export function splitTemplate(template, mountId = 'root') {
|
|
221
|
+
const mount = new RegExp(`<div id=["']${mountId}["']\\s*>\\s*</div>`);
|
|
222
|
+
const m = template.match(mount);
|
|
223
|
+
if (m && m.index !== undefined) {
|
|
224
|
+
return {
|
|
225
|
+
head: template.slice(0, m.index) + `<div id="${mountId}">`,
|
|
226
|
+
tail: `</div>` + template.slice(m.index + m[0].length),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const outlet = template.indexOf('<!--ssr-outlet-->');
|
|
230
|
+
if (outlet !== -1) {
|
|
231
|
+
return {
|
|
232
|
+
head: template.slice(0, outlet),
|
|
233
|
+
tail: template.slice(outlet + '<!--ssr-outlet-->'.length),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const body = template.indexOf('</body>');
|
|
237
|
+
if (body !== -1) {
|
|
238
|
+
return {
|
|
239
|
+
head: template.slice(0, body) + `<div id="${mountId}">`,
|
|
240
|
+
tail: `</div>` + template.slice(body),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { head: template, tail: '' };
|
|
244
|
+
}
|
|
245
|
+
/** Set/replace an attribute on the template's <html> tag (e.g. lang). */
|
|
246
|
+
function setHtmlAttr(html, name, value) {
|
|
247
|
+
const existing = new RegExp(`(<html\\b[^>]*?)\\s${name}="[^"]*"`, 'i');
|
|
248
|
+
if (existing.test(html))
|
|
249
|
+
return html.replace(existing, `$1 ${name}="${value}"`);
|
|
250
|
+
return html.replace(/<html\b/i, `<html ${name}="${value}"`);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Inject the rendered app into the mount point AND lift any @fluixi/head metadata (carried back as
|
|
254
|
+
* a marker by renderRequestAsync) into <head> + <html>. If the page set a title, the template's own
|
|
255
|
+
* <title> is dropped so there's only one. No marker → identical to injectApp.
|
|
256
|
+
*/
|
|
257
|
+
export function injectAppAndHead(template, rendered, mountId = 'root') {
|
|
258
|
+
const { head, body } = extractHeadMarker(rendered);
|
|
259
|
+
let html = injectApp(template, body, mountId);
|
|
260
|
+
if (head) {
|
|
261
|
+
if (head.headHtml) {
|
|
262
|
+
if (/<title[\s>]/i.test(head.headHtml))
|
|
263
|
+
html = html.replace(/<title>[\s\S]*?<\/title>/i, '');
|
|
264
|
+
html = html.replace('</head>', `${head.headHtml}</head>`);
|
|
265
|
+
}
|
|
266
|
+
for (const [k, v] of Object.entries(head.htmlAttrs))
|
|
267
|
+
html = setHtmlAttr(html, k, v);
|
|
268
|
+
}
|
|
269
|
+
return html;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Wrap a fetch handler so a thrown/rejected error becomes a 500 instead of an
|
|
273
|
+
* unhandled rejection. `createRequestHandler` already guards the renderer; this
|
|
274
|
+
* covers the middleware chain that wraps it. `onError` lets dev map the stack first.
|
|
275
|
+
*/
|
|
276
|
+
export function guardHandler(handler, onError) {
|
|
277
|
+
return async (request) => {
|
|
278
|
+
try {
|
|
279
|
+
return await handler(request);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
onError?.(e);
|
|
283
|
+
return new Response(String(e?.stack || e), {
|
|
284
|
+
status: 500,
|
|
285
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
let _guardsInstalled = false;
|
|
291
|
+
/**
|
|
292
|
+
* A render fires the route's data fetches eagerly; if the backend is down those
|
|
293
|
+
* promises can reject AFTER we've already responded, with nothing awaiting them —
|
|
294
|
+
* a floating rejection would kill the dev server. Swallow + log instead.
|
|
295
|
+
*/
|
|
296
|
+
export function installCrashGuards() {
|
|
297
|
+
if (_guardsInstalled)
|
|
298
|
+
return;
|
|
299
|
+
_guardsInstalled = true;
|
|
300
|
+
process.on('unhandledRejection', (reason) => console.warn('[fluixi] unhandled rejection (backend likely down) —', reason?.message || reason));
|
|
301
|
+
process.on('uncaughtException', (err) => console.warn('[fluixi] uncaught exception —', err?.message || err));
|
|
302
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request middleware — a chain that runs BEFORE the SSR render, in both `fluixi dev`
|
|
3
|
+
* and `fluixi start`. Each middleware gets `(request, next)`; calling `next()` runs
|
|
4
|
+
* the rest of the chain (ending in the renderer). A middleware may short-circuit
|
|
5
|
+
* (return a Response without calling `next` — e.g. an auth redirect or a 401), or
|
|
6
|
+
* call `next()` and adjust the result (add headers, rewrite). Runtime-neutral: a
|
|
7
|
+
* middleware sees a Web `Request` and returns a Web `Response`.
|
|
8
|
+
*
|
|
9
|
+
* Convention: `src/middleware.ts`, default-exporting `defineMiddleware([...])`.
|
|
10
|
+
*
|
|
11
|
+
* // src/middleware.ts
|
|
12
|
+
* import { defineMiddleware } from '@fluixi/start';
|
|
13
|
+
* export default defineMiddleware([
|
|
14
|
+
* (req, next) => {
|
|
15
|
+
* const url = new URL(req.url);
|
|
16
|
+
* if (url.pathname.startsWith('/admin') && !isAuthed(req))
|
|
17
|
+
* return new Response(null, { status: 302, headers: { location: '/login' } });
|
|
18
|
+
* return next();
|
|
19
|
+
* },
|
|
20
|
+
* ]);
|
|
21
|
+
*/
|
|
22
|
+
import type { FetchHandler } from './handler.js';
|
|
23
|
+
export type MiddlewareNext = () => Promise<Response>;
|
|
24
|
+
export type Middleware = (request: Request, next: MiddlewareNext) => Response | Promise<Response>;
|
|
25
|
+
/** Identity helper for `export default` — accepts one middleware or an array. */
|
|
26
|
+
export declare function defineMiddleware(mw: Middleware | Middleware[]): Middleware[];
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a loaded `src/middleware.ts` module's default export into a Middleware[].
|
|
29
|
+
* Tolerates a single function, an array, `defineMiddleware(...)` output, or nothing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function normalizeMiddleware(mod: unknown): Middleware[];
|
|
32
|
+
/**
|
|
33
|
+
* Compose a middleware chain around a core handler (the renderer). Returns a handler
|
|
34
|
+
* that runs `middlewares[0] → … → core`. `next()` must be called at most once per
|
|
35
|
+
* middleware; a second call rejects.
|
|
36
|
+
*/
|
|
37
|
+
export declare function composeMiddleware(middlewares: Middleware[], core: FetchHandler): FetchHandler;
|
|
38
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,MAAM,MAAM,cAAc,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,CAAC;AACrD,MAAM,MAAM,UAAU,GAAG,CACvB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,cAAc,KACjB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,iFAAiF;AACjF,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,UAAU,GAAG,UAAU,EAAE,GAAG,UAAU,EAAE,CAE5E;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,EAAE,CAK9D;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,UAAU,EAAE,EACzB,IAAI,EAAE,YAAY,GACjB,YAAY,CAed"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Identity helper for `export default` — accepts one middleware or an array. */
|
|
2
|
+
export function defineMiddleware(mw) {
|
|
3
|
+
return Array.isArray(mw) ? mw.slice() : [mw];
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a loaded `src/middleware.ts` module's default export into a Middleware[].
|
|
7
|
+
* Tolerates a single function, an array, `defineMiddleware(...)` output, or nothing.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeMiddleware(mod) {
|
|
10
|
+
const def = mod?.default ?? mod;
|
|
11
|
+
if (def == null)
|
|
12
|
+
return [];
|
|
13
|
+
if (Array.isArray(def))
|
|
14
|
+
return def.filter((m) => typeof m === 'function');
|
|
15
|
+
return typeof def === 'function' ? [def] : [];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Compose a middleware chain around a core handler (the renderer). Returns a handler
|
|
19
|
+
* that runs `middlewares[0] → … → core`. `next()` must be called at most once per
|
|
20
|
+
* middleware; a second call rejects.
|
|
21
|
+
*/
|
|
22
|
+
export function composeMiddleware(middlewares, core) {
|
|
23
|
+
if (middlewares.length === 0)
|
|
24
|
+
return core;
|
|
25
|
+
return (request) => {
|
|
26
|
+
let lastCalled = -1;
|
|
27
|
+
const dispatch = (i) => {
|
|
28
|
+
if (i <= lastCalled) {
|
|
29
|
+
return Promise.reject(new Error('middleware called next() more than once'));
|
|
30
|
+
}
|
|
31
|
+
lastCalled = i;
|
|
32
|
+
const mw = middlewares[i];
|
|
33
|
+
if (!mw)
|
|
34
|
+
return Promise.resolve(core(request));
|
|
35
|
+
return Promise.resolve(mw(request, () => dispatch(i + 1)));
|
|
36
|
+
};
|
|
37
|
+
return dispatch(0);
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fluixi/start/router` — the app-facing routing surface, re-exported from
|
|
3
|
+
* `@fluixi/core/router-next` (the framework-agnostic `@fluixi/router` core + its
|
|
4
|
+
* fluixi adapter). Importing routing from the meta-framework gives apps a single,
|
|
5
|
+
* stable dependency surface — they don't reach into fluixi internals:
|
|
6
|
+
*
|
|
7
|
+
* import { Router, Outlet, Link, createMemoryHistory } from '@fluixi/start/router';
|
|
8
|
+
*
|
|
9
|
+
* Framework primitives (Suspense, lazy, signals, components) still come from
|
|
10
|
+
* `@fluixi/core` — only routing lives here.
|
|
11
|
+
*/
|
|
12
|
+
export * from '@fluixi/core/router-next';
|
|
13
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,cAAc,0BAA0B,CAAC"}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fluixi/start/router` — the app-facing routing surface, re-exported from
|
|
3
|
+
* `@fluixi/core/router-next` (the framework-agnostic `@fluixi/router` core + its
|
|
4
|
+
* fluixi adapter). Importing routing from the meta-framework gives apps a single,
|
|
5
|
+
* stable dependency surface — they don't reach into fluixi internals:
|
|
6
|
+
*
|
|
7
|
+
* import { Router, Outlet, Link, createMemoryHistory } from '@fluixi/start/router';
|
|
8
|
+
*
|
|
9
|
+
* Framework primitives (Suspense, lazy, signals, components) still come from
|
|
10
|
+
* `@fluixi/core` — only routing lives here.
|
|
11
|
+
*/
|
|
12
|
+
export * from '@fluixi/core/router-next';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only bridge: wire `@fluixi/server`'s request context into the server-function
|
|
3
|
+
* dispatcher so `getRequestEvent()` works inside a `"use server"` function. Imported by
|
|
4
|
+
* `virtual:fluixi-server-fns` (server build only) — never by the client.
|
|
5
|
+
*/
|
|
6
|
+
import { getRequestEvent } from '@fluixi/server';
|
|
7
|
+
export { getRequestEvent };
|
|
8
|
+
//# sourceMappingURL=server-fn-setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-fn-setup.d.ts","sourceRoot":"","sources":["../src/server-fn-setup.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAA+C,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAa9F,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only bridge: wire `@fluixi/server`'s request context into the server-function
|
|
3
|
+
* dispatcher so `getRequestEvent()` works inside a `"use server"` function. Imported by
|
|
4
|
+
* `virtual:fluixi-server-fns` (server build only) — never by the client.
|
|
5
|
+
*/
|
|
6
|
+
import { createRequestContext, runWithRequestContext, getRequestEvent } from '@fluixi/server';
|
|
7
|
+
import { setServerFnContextRunner } from './server-fn.js';
|
|
8
|
+
setServerFnContextRunner((request, run) => {
|
|
9
|
+
const responseHeaders = {};
|
|
10
|
+
const event = { request, url: new URL(request.url).pathname, responseHeaders };
|
|
11
|
+
const ctx = createRequestContext(event);
|
|
12
|
+
const result = Promise.resolve(runWithRequestContext(ctx, run));
|
|
13
|
+
return { result, responseHeaders };
|
|
14
|
+
});
|
|
15
|
+
// Re-exported so a server function can `import { getRequestEvent } from '@fluixi/start/server-fn-setup'`
|
|
16
|
+
// — though importing from `@fluixi/server` directly works too.
|
|
17
|
+
export { getRequestEvent };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-functions runtime — the target of the `"use server"` compiler transform.
|
|
3
|
+
* Client-safe (web APIs only, no node imports): on the client the registry/dispatcher
|
|
4
|
+
* are dead code and the stub `fetch()`es the RPC endpoint; on the server the bodies are
|
|
5
|
+
* registered and `handleServerFn` dispatches them.
|
|
6
|
+
*/
|
|
7
|
+
/** The RPC endpoint server functions POST to. */
|
|
8
|
+
export declare const SERVER_FN_ENDPOINT = "/_server";
|
|
9
|
+
/** Server build: register a server function body by its stable id. Returns it. */
|
|
10
|
+
export declare function $$registerServerFn<F extends (...args: any[]) => any>(id: string, fn: F): F;
|
|
11
|
+
/** Client build: an RPC stub that calls the server function by id. */
|
|
12
|
+
export declare function $$createServerReference(id: string): (...args: any[]) => Promise<any>;
|
|
13
|
+
/**
|
|
14
|
+
* Runs a server function inside a request context so `getRequestEvent()` (request,
|
|
15
|
+
* headers, cookies) works inside it. Injected by `@fluixi/start/server-fn-setup` (which
|
|
16
|
+
* bridges `@fluixi/server`'s AsyncLocalStorage) so this module stays client-safe — no
|
|
17
|
+
* node imports here. `event.responseHeaders` set inside the fn are returned to the host.
|
|
18
|
+
*/
|
|
19
|
+
type ContextRunner = (request: Request, run: () => any) => {
|
|
20
|
+
result: Promise<any>;
|
|
21
|
+
responseHeaders?: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
export declare function setServerFnContextRunner(runner: ContextRunner | null): void;
|
|
24
|
+
export declare function setServerFnFetch(f: typeof fetch): void;
|
|
25
|
+
/** True if `request` is a server-function RPC call (so the host should dispatch it). */
|
|
26
|
+
export declare function isServerFnRequest(request: Request): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Dispatch a server-function RPC: same-origin (CSRF) check, look up the id, run it with
|
|
29
|
+
* the JSON args, return the JSON result. A thrown error → 500; unknown id → 404.
|
|
30
|
+
*/
|
|
31
|
+
export declare function handleServerFn(request: Request): Promise<Response>;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=server-fn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-fn.d.ts","sourceRoot":"","sources":["../src/server-fn.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,aAAa,CAAC;AAO7C,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAG1F;AAED,sEAAsE;AACtE,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,IACxC,GAAG,MAAM,GAAG,EAAE,kBACvB;AAED;;;;;GAKG;AACH,KAAK,aAAa,GAAG,CACnB,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,MAAM,GAAG,KACX;IAAE,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAExE,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI,CAE3E;AAKD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,KAAK,GAAG,IAAI,CAEtD;AAgBD,wFAAwF;AACxF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAE3D;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAyCxE"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-functions runtime — the target of the `"use server"` compiler transform.
|
|
3
|
+
* Client-safe (web APIs only, no node imports): on the client the registry/dispatcher
|
|
4
|
+
* are dead code and the stub `fetch()`es the RPC endpoint; on the server the bodies are
|
|
5
|
+
* registered and `handleServerFn` dispatches them.
|
|
6
|
+
*/
|
|
7
|
+
/** The RPC endpoint server functions POST to. */
|
|
8
|
+
export const SERVER_FN_ENDPOINT = '/_server';
|
|
9
|
+
const HEADER = 'x-fluixi-server-fn';
|
|
10
|
+
// id → server function. Populated at module init on the server (the transform emits
|
|
11
|
+
// `$$registerServerFn` calls); empty/unused on the client.
|
|
12
|
+
const registry = new Map();
|
|
13
|
+
/** Server build: register a server function body by its stable id. Returns it. */
|
|
14
|
+
export function $$registerServerFn(id, fn) {
|
|
15
|
+
registry.set(id, fn);
|
|
16
|
+
return fn;
|
|
17
|
+
}
|
|
18
|
+
/** Client build: an RPC stub that calls the server function by id. */
|
|
19
|
+
export function $$createServerReference(id) {
|
|
20
|
+
return (...args) => callServer(id, args);
|
|
21
|
+
}
|
|
22
|
+
let contextRunner = null;
|
|
23
|
+
export function setServerFnContextRunner(runner) {
|
|
24
|
+
contextRunner = runner;
|
|
25
|
+
}
|
|
26
|
+
// The fetch the RPC client uses. Pluggable so interceptors (auth headers, 401 handling,
|
|
27
|
+
// retry) apply to server-function calls — `addInterceptor` swaps in the intercepted fetch.
|
|
28
|
+
let _fetch = (...a) => fetch(...a);
|
|
29
|
+
export function setServerFnFetch(f) {
|
|
30
|
+
_fetch = f;
|
|
31
|
+
}
|
|
32
|
+
async function callServer(id, args) {
|
|
33
|
+
const res = await _fetch(SERVER_FN_ENDPOINT, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json', [HEADER]: id },
|
|
36
|
+
body: JSON.stringify(args),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`server function "${id}" failed: ${res.status} ${await res.text()}`);
|
|
40
|
+
}
|
|
41
|
+
return (res.headers.get('content-type') || '').includes('application/json')
|
|
42
|
+
? res.json()
|
|
43
|
+
: res.text();
|
|
44
|
+
}
|
|
45
|
+
/** True if `request` is a server-function RPC call (so the host should dispatch it). */
|
|
46
|
+
export function isServerFnRequest(request) {
|
|
47
|
+
return request.method === 'POST' && request.headers.has(HEADER);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch a server-function RPC: same-origin (CSRF) check, look up the id, run it with
|
|
51
|
+
* the JSON args, return the JSON result. A thrown error → 500; unknown id → 404.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleServerFn(request) {
|
|
54
|
+
// CSRF: reject cross-origin POSTs (a same-origin fetch always sends a matching Origin).
|
|
55
|
+
const origin = request.headers.get('origin');
|
|
56
|
+
if (origin) {
|
|
57
|
+
try {
|
|
58
|
+
if (new URL(origin).host !== new URL(request.url).host) {
|
|
59
|
+
return new Response('forbidden', { status: 403 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return new Response('forbidden', { status: 403 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const id = request.headers.get(HEADER);
|
|
67
|
+
const fn = registry.get(id);
|
|
68
|
+
if (!fn)
|
|
69
|
+
return new Response(`unknown server function: ${id}`, { status: 404 });
|
|
70
|
+
let args = [];
|
|
71
|
+
try {
|
|
72
|
+
const parsed = await request.json();
|
|
73
|
+
args = Array.isArray(parsed) ? parsed : [parsed];
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* no/!json body → no args */
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const headers = { 'content-type': 'application/json' };
|
|
80
|
+
let result;
|
|
81
|
+
if (contextRunner) {
|
|
82
|
+
// Run inside the request context so getRequestEvent() works in the fn; collect any
|
|
83
|
+
// response headers (e.g. Set-Cookie) the fn set on the event.
|
|
84
|
+
const ran = contextRunner(request, () => fn(...args));
|
|
85
|
+
result = await ran.result;
|
|
86
|
+
Object.assign(headers, ran.responseHeaders || {});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
result = await fn(...args);
|
|
90
|
+
}
|
|
91
|
+
return new Response(JSON.stringify(result ?? null), { status: 200, headers });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return new Response(String(e?.message || e), { status: 500 });
|
|
95
|
+
}
|
|
96
|
+
}
|