rosefc 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/deploy/Dockerfile.node +25 -0
- package/deploy/Dockerfile.static +34 -0
- package/deploy/api/rosefd.ts +19 -0
- package/deploy/cloudflare/functions/[[path]].ts +59 -0
- package/deploy/cloudflare/wrangler.toml +14 -0
- package/deploy/docker-compose.yml +45 -0
- package/deploy/fly.toml +28 -0
- package/deploy/k8s/rosefd.yaml +128 -0
- package/deploy/native/RosefdApp.xcscheme +87 -0
- package/deploy/native/android.yml +62 -0
- package/deploy/native/flutter.yml +96 -0
- package/deploy/native/ios.yml +67 -0
- package/deploy/netlify/functions/rosefd.ts +36 -0
- package/deploy/netlify.toml +17 -0
- package/deploy/server.cjs +4 -0
- package/deploy/vercel.json +13 -0
- package/dist-cli/compiler.mjs +1693 -0
- package/dist-cli/edge.mjs +199 -0
- package/dist-cli/export.mjs +12287 -0
- package/dist-cli/rosefd.mjs +12783 -0
- package/dist-cli/runtime.mjs +6 -0
- package/dist-cli/test.mjs +1885 -0
- package/package.json +110 -4
- package/src/FRAMEWORK_VERSION +1 -0
- package/src/cli/content.ts +188 -0
- package/src/cli/edge.ts +298 -0
- package/src/cli/hot-vary.ts +27 -0
- package/src/cli/origin.ts +10 -0
- package/src/cli/prod-server.ts +817 -0
- package/src/cli/shell.ts +610 -0
- package/src/cli/shutdown.ts +56 -0
- package/src/cli/validator.ts +53 -0
- package/src/hash.ts +9 -0
- package/src/runtime/db.ts +166 -0
- package/src/runtime/index.ts +2992 -0
- package/src/runtime/lorawan.ts +149 -0
- package/src/types/rosefd.d.ts +327 -0
- package/README.md +0 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The conditional-request validator a dynamic response answers with.
|
|
2
|
+
//
|
|
3
|
+
// A server-side cache key can name a representation perfectly and still hand
|
|
4
|
+
// the client the wrong answer, because the client does not compare keys - it
|
|
5
|
+
// compares validators. The prod server used to derive a non-HTML entry's
|
|
6
|
+
// validator from `raw.length` plus the cookie (BUG.md AM): an anonymous
|
|
7
|
+
// reader's cookie is the empty string, so the validator only moved when the
|
|
8
|
+
// byte count moved. Two equal-length representations with different bytes - a
|
|
9
|
+
// same-length typo fix, a re-rendered feed of the same size - therefore shared
|
|
10
|
+
// one validator, and every conditional request against the newer entry answered
|
|
11
|
+
// 304 with the older bytes: a false 304 that survives until the length changes.
|
|
12
|
+
//
|
|
13
|
+
// The rule that closes it is content-derived and cheap: hash what is about to
|
|
14
|
+
// go on the wire. sha1 is not a collision-resistant signature any more, but a
|
|
15
|
+
// validator only needs the property that a byte change flips it, and RFC 9110
|
|
16
|
+
// lets a weak validator (`W/"..."`) be answered with 200 whenever the client's
|
|
17
|
+
// copy is not byte-equivalent. sha1 over a render that already cost a route
|
|
18
|
+
// lookup and a serialization is noise on the budget.
|
|
19
|
+
//
|
|
20
|
+
// Ownership still belongs to the origin server: an app that publishes its own
|
|
21
|
+
// `ETag` keeps it. The framework only synthesizes one where the response has
|
|
22
|
+
// none, so an application-side validator (a database version stamp, a content
|
|
23
|
+
// hash of the pre-serialized row) is never silently replaced underneath a
|
|
24
|
+
// client that cached it.
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
/** A weak, content-derived validator for the exact bytes going on the wire. */
|
|
28
|
+
export function contentEtag(raw: Buffer): string {
|
|
29
|
+
return 'W/"' + createHash('sha1').update(raw).digest('base64url').slice(0, 24) + '"';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The validator this response carries: the app's own if it published one,
|
|
33
|
+
* otherwise the hash of its body. Both the exit that answers the request and
|
|
34
|
+
* the cache entry that replays it must agree, or a hot replay would mint a
|
|
35
|
+
* validator the cold render never showed. */
|
|
36
|
+
export function responseEtag(response: { headers: Headers }, raw: Buffer): string {
|
|
37
|
+
return response.headers.get('etag') ?? contentEtag(raw);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** RFC 9110 §13.1.2: `If-None-Match` is a list, `*` matches any current
|
|
41
|
+
* representation, and the comparison is weak - the `W/` prefix is not part of
|
|
42
|
+
* the identity, so a client that echoed a strong tag still revalidates. */
|
|
43
|
+
export function ifNoneMatch(header: string | string[] | undefined | null, etag: string): boolean {
|
|
44
|
+
if (!header) return false;
|
|
45
|
+
const bare = etag.startsWith('W/') ? etag.slice(2) : etag;
|
|
46
|
+
for (const part of (Array.isArray(header) ? header.join(',') : header).split(',')) {
|
|
47
|
+
const token = part.trim();
|
|
48
|
+
if (!token) continue;
|
|
49
|
+
if (token === '*') return true;
|
|
50
|
+
if ((token.startsWith('W/') ? token.slice(2) : token) === bare) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function fnv1a32(input: Uint8Array | string): string {
|
|
2
|
+
let h = 0x811c9dc5;
|
|
3
|
+
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
|
|
4
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
5
|
+
h ^= bytes[i];
|
|
6
|
+
h = Math.imul(h, 0x01000193);
|
|
7
|
+
}
|
|
8
|
+
return (h >>> 0).toString(36);
|
|
9
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// src/runtime/db.ts - the database helper tier for pages/api/*.rose handlers.
|
|
2
|
+
//
|
|
3
|
+
// Rose ships no database driver: pg, mysql2 and node:sqlite are three
|
|
4
|
+
// incompatible shapes, and none of them belongs in a runtime whose entire
|
|
5
|
+
// published dependency list is esbuild. So the app supplies ONE object that
|
|
6
|
+
// can run a query - the `Driver` below - and every handler talks to the typed
|
|
7
|
+
// helpers in this file. Swapping SQLite for Postgres is then a one-file
|
|
8
|
+
// change (the driver), not a rewrite of twenty routes.
|
|
9
|
+
//
|
|
10
|
+
// WIRING (once, at boot, in a server-only place - src/pages/_middleware.rose,
|
|
11
|
+
// or a module that file imports; never a page: a page script ships to the
|
|
12
|
+
// browser). Register there, and handlers call q()/q1()/tx() with no driver
|
|
13
|
+
// import at all.
|
|
14
|
+
//
|
|
15
|
+
// // Postgres. connect() is what makes tx() correct: a pool hands out a
|
|
16
|
+
// // different connection per query, so BEGIN/COMMIT on `pool.query` would
|
|
17
|
+
// // run on different sockets. One connection for the whole transaction.
|
|
18
|
+
// import { Pool } from 'pg';
|
|
19
|
+
// import { registerDb } from '../../src/runtime/db'; // flat-build-relative
|
|
20
|
+
// const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
21
|
+
// registerDb({
|
|
22
|
+
// query: (sql, params) => pool.query(sql, params as any[]),
|
|
23
|
+
// async connect() {
|
|
24
|
+
// const c = await pool.connect();
|
|
25
|
+
// return { query: (sql, params) => c.query(sql, params as any[]), release: () => c.release() };
|
|
26
|
+
// },
|
|
27
|
+
// });
|
|
28
|
+
//
|
|
29
|
+
// // mysql2/promise is the same two lines over `pool.execute`.
|
|
30
|
+
//
|
|
31
|
+
// // SQLite via node:sqlite: no connect() - one connection IS the pool, so
|
|
32
|
+
// // tx() sends BEGIN/COMMIT/ROLLBACK through query() on it.
|
|
33
|
+
// const sqlite = new DatabaseSync('blog.db');
|
|
34
|
+
// registerDb({ query: async (sql, params) => ({ rows: sqlite.prepare(sql).all(...(params ?? [])) }) });
|
|
35
|
+
//
|
|
36
|
+
// Then, in any handler:
|
|
37
|
+
//
|
|
38
|
+
// const posts = await q<PostRow>('SELECT * FROM posts ORDER BY id DESC');
|
|
39
|
+
// const post = await q1<PostRow>('SELECT * FROM posts WHERE id = ?', [id]); // null when absent
|
|
40
|
+
// await tx(async (t) => { // all or nothing
|
|
41
|
+
// await t.q1('INSERT INTO posts (title) VALUES (?) RETURNING id', [title]);
|
|
42
|
+
// await t.q('UPDATE counters SET n = n + 1 WHERE k = ?', ['posts']);
|
|
43
|
+
// });
|
|
44
|
+
//
|
|
45
|
+
// A handler that returns q()'s rows is enough on its own: an exported API
|
|
46
|
+
// function's plain object return value is serialized as JSON.
|
|
47
|
+
//
|
|
48
|
+
// The driver lives on globalThis, not in a module-level `let`, because the
|
|
49
|
+
// runtime is bundled once per page (sliced and per-page inline bundles each
|
|
50
|
+
// carry their own copy of this module) - a module-level variable would be
|
|
51
|
+
// per-copy, so a route would see "no driver registered" from a different
|
|
52
|
+
// bundle's registration. Same reason the rest of the server runtime publishes
|
|
53
|
+
// itself through __rosefdAuth / __rosefdKvApi / __rosefdExtras.
|
|
54
|
+
//
|
|
55
|
+
// Zero dependencies: no import statement in this file at all.
|
|
56
|
+
|
|
57
|
+
/** The one thing an app must supply. `query` is async even for a sync driver. */
|
|
58
|
+
export interface Driver {
|
|
59
|
+
query(sql: string, params?: readonly unknown[]): Promise<{ rows: any[] }>;
|
|
60
|
+
/**
|
|
61
|
+
* Optional: a dedicated connection for a transaction, released when tx()
|
|
62
|
+
* ends. Required for a POOL (pg, mysql2) - without it tx() runs BEGIN and
|
|
63
|
+
* COMMIT through query() and two different sockets would each hold half a
|
|
64
|
+
* transaction.
|
|
65
|
+
*/
|
|
66
|
+
connect?(): Promise<Conn>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What connect() returns: the connection, plus the release that returns it. */
|
|
70
|
+
export interface Conn extends Driver {
|
|
71
|
+
release(): void | Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The query helpers. What tx() hands its callback, so it cannot nest. */
|
|
75
|
+
export interface Queries {
|
|
76
|
+
/** Every matching row. `[]` when none match. */
|
|
77
|
+
q<T = any>(sql: string, params?: readonly unknown[]): Promise<T[]>;
|
|
78
|
+
/** The first matching row, or null. Rows are untyped - you name the shape. */
|
|
79
|
+
q1<T = any>(sql: string, params?: readonly unknown[]): Promise<T | null>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The registered database: the query helpers plus transactions. */
|
|
83
|
+
export interface Db extends Queries {
|
|
84
|
+
tx<T>(fn: (t: Queries) => Promise<T> | T): Promise<T>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const KEY = '__rosefdDb';
|
|
88
|
+
|
|
89
|
+
const slot = () => globalThis as unknown as Record<string, Driver | undefined>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Publish the app's driver. Call once at boot, before the first request;
|
|
93
|
+
* calling again (a dev hot reload) simply replaces it.
|
|
94
|
+
*/
|
|
95
|
+
export function registerDb(driver: Driver): void {
|
|
96
|
+
slot()[KEY] = driver;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireDriver(): Driver {
|
|
100
|
+
const driver = slot()[KEY];
|
|
101
|
+
if (!driver) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'rosefd/db: no database driver registered - call registerDb({ query }) once at boot ' +
|
|
104
|
+
'(src/pages/_middleware.rose, or a module it imports) before any handler queries',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return driver;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const on = (run: Driver): Queries => ({
|
|
111
|
+
q: async <T>(sql: string, params?: readonly unknown[]): Promise<T[]> => (await run.query(sql, params)).rows as T[],
|
|
112
|
+
q1: async <T>(sql: string, params?: readonly unknown[]): Promise<T | null> => {
|
|
113
|
+
const { rows } = await run.query(sql, params);
|
|
114
|
+
return (rows[0] ?? null) as T | null;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// q/q1 are `async` rather than plain `Promise`-returning wrappers so a missing
|
|
119
|
+
// driver surfaces as a REJECTED promise: a handler written `export function
|
|
120
|
+
// GET()` (not async) can still await/.catch it instead of crashing at call
|
|
121
|
+
// time on a synchronous throw.
|
|
122
|
+
/** Every matching row, on the registered driver. */
|
|
123
|
+
export async function q<T = any>(sql: string, params?: readonly unknown[]): Promise<T[]> {
|
|
124
|
+
return on(requireDriver()).q<T>(sql, params);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The first matching row, or null, on the registered driver. */
|
|
128
|
+
export async function q1<T = any>(sql: string, params?: readonly unknown[]): Promise<T | null> {
|
|
129
|
+
return on(requireDriver()).q1<T>(sql, params);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run `fn` inside one transaction: BEGIN, then COMMIT on success, ROLLBACK and
|
|
134
|
+
* rethrow on failure. The callback's handle is connection-scoped (queries go
|
|
135
|
+
* to the same connection the BEGIN went to) and deliberately has no tx(), so a
|
|
136
|
+
* nested transaction cannot deadlock two connections against each other.
|
|
137
|
+
*/
|
|
138
|
+
export async function tx<T>(fn: (t: Queries) => Promise<T> | T): Promise<T> {
|
|
139
|
+
const driver = requireDriver();
|
|
140
|
+
const conn = driver.connect ? await driver.connect() : null;
|
|
141
|
+
const run = conn ?? driver;
|
|
142
|
+
try {
|
|
143
|
+
await run.query('BEGIN');
|
|
144
|
+
const out = await fn(on(run));
|
|
145
|
+
await run.query('COMMIT');
|
|
146
|
+
return out;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// The original failure is the one worth reporting; a rollback that also
|
|
149
|
+
// fails (a dropped socket) must not replace it.
|
|
150
|
+
try {
|
|
151
|
+
await run.query('ROLLBACK');
|
|
152
|
+
} catch { /* the error thrown below is the truth */ }
|
|
153
|
+
throw err;
|
|
154
|
+
} finally {
|
|
155
|
+
if (conn) await conn.release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The registered database, for handlers that would rather hold an object. */
|
|
160
|
+
export function db(): Db {
|
|
161
|
+
return {
|
|
162
|
+
q: (sql, params) => q(sql, params),
|
|
163
|
+
q1: (sql, params) => q1(sql, params),
|
|
164
|
+
tx: (fn) => tx(fn),
|
|
165
|
+
};
|
|
166
|
+
}
|