toiljs 0.0.34 → 0.0.36

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -0
  3. package/as-pect.config.js +8 -2
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +97 -0
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +179 -0
  9. package/build/client/index.d.ts +5 -1
  10. package/build/client/index.js +3 -1
  11. package/build/client/routing/loader.d.ts +1 -0
  12. package/build/client/routing/loader.js +37 -0
  13. package/build/client/routing/mount.js +32 -1
  14. package/build/client/ssr/markers.d.ts +34 -0
  15. package/build/client/ssr/markers.js +49 -0
  16. package/build/compiler/.tsbuildinfo +1 -1
  17. package/build/compiler/docs.js +88 -1
  18. package/build/compiler/generate.d.ts +2 -0
  19. package/build/compiler/generate.js +2 -2
  20. package/build/compiler/index.js +2 -0
  21. package/build/compiler/ssr-codegen.d.ts +2 -0
  22. package/build/compiler/ssr-codegen.js +36 -0
  23. package/build/compiler/template-build.d.ts +29 -0
  24. package/build/compiler/template-build.js +150 -0
  25. package/build/compiler/template.d.ts +22 -0
  26. package/build/compiler/template.js +169 -0
  27. package/build/devserver/.tsbuildinfo +1 -1
  28. package/build/devserver/crypto.js +15 -0
  29. package/build/devserver/host.js +1 -0
  30. package/build/devserver/module.d.ts +1 -0
  31. package/build/devserver/module.js +23 -1
  32. package/docs/README.md +56 -0
  33. package/docs/auth.md +261 -0
  34. package/docs/caching.md +115 -0
  35. package/docs/cookies.md +457 -0
  36. package/docs/crypto.md +130 -0
  37. package/docs/data.md +131 -0
  38. package/docs/getting-started.md +128 -0
  39. package/docs/routing.md +259 -0
  40. package/docs/rpc.md +149 -0
  41. package/docs/ssr.md +184 -0
  42. package/docs/time.md +43 -0
  43. package/examples/basic/client/routes/auth.tsx +198 -0
  44. package/examples/basic/client/routes/cookies.tsx +199 -0
  45. package/examples/basic/client/routes/features/index.tsx +34 -10
  46. package/examples/basic/client/routes/hello.tsx +43 -0
  47. package/examples/basic/client/routes/pq.tsx +135 -0
  48. package/examples/basic/server/AuthTestHandler.ts +15 -0
  49. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  50. package/examples/basic/server/CacheHandler.ts +25 -0
  51. package/examples/basic/server/DecoCache.ts +18 -0
  52. package/examples/basic/server/FastTrapHandler.ts +8 -0
  53. package/examples/basic/server/SpinHandler.ts +18 -0
  54. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  55. package/examples/basic/server/authexample-main.ts +8 -0
  56. package/examples/basic/server/authtest-main.ts +8 -0
  57. package/examples/basic/server/authverify-main.ts +8 -0
  58. package/examples/basic/server/cache-main.ts +8 -0
  59. package/examples/basic/server/core/AppHandler.ts +243 -0
  60. package/examples/basic/server/deco-main.ts +18 -0
  61. package/examples/basic/server/main.ts +2 -0
  62. package/examples/basic/server/routes/Auth.ts +184 -0
  63. package/examples/basic/server/routes/PqDemo.ts +109 -0
  64. package/examples/basic/server/routes/Session.ts +73 -0
  65. package/examples/basic/server/spin-main.ts +13 -0
  66. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  67. package/examples/basic/server/ssr-main.ts +18 -0
  68. package/examples/basic/server/toil-server-env.d.ts +94 -0
  69. package/examples/basic/server/trap-main.ts +8 -0
  70. package/package.json +5 -3
  71. package/server/globals/auth.ts +281 -0
  72. package/server/runtime/README.md +61 -0
  73. package/server/runtime/env/Server.ts +12 -0
  74. package/server/runtime/exports/index.ts +17 -0
  75. package/server/runtime/exports/render.ts +51 -0
  76. package/server/runtime/http/base64.ts +104 -0
  77. package/server/runtime/http/cookie.ts +416 -0
  78. package/server/runtime/http/cookies.ts +197 -0
  79. package/server/runtime/http/date.ts +72 -0
  80. package/server/runtime/http/percent.ts +76 -0
  81. package/server/runtime/http/securecookies.ts +224 -0
  82. package/server/runtime/index.ts +17 -0
  83. package/server/runtime/request.ts +24 -0
  84. package/server/runtime/response.ts +29 -0
  85. package/server/runtime/ssr/Ssr.ts +43 -0
  86. package/server/runtime/ssr/encode.ts +110 -0
  87. package/server/runtime/ssr/escape.ts +83 -0
  88. package/server/runtime/ssr/slots.ts +144 -0
  89. package/server/runtime/time.ts +29 -0
  90. package/src/cli/create.ts +105 -0
  91. package/src/client/auth.ts +322 -0
  92. package/src/client/index.ts +5 -1
  93. package/src/client/routing/loader.ts +56 -0
  94. package/src/client/routing/mount.tsx +37 -1
  95. package/src/client/ssr/markers.tsx +140 -0
  96. package/src/compiler/docs.ts +88 -1
  97. package/src/compiler/generate.ts +2 -2
  98. package/src/compiler/index.ts +5 -0
  99. package/src/compiler/ssr-codegen.ts +85 -0
  100. package/src/compiler/template-build.ts +275 -0
  101. package/src/compiler/template.ts +265 -0
  102. package/src/devserver/crypto.ts +23 -0
  103. package/src/devserver/host.ts +4 -0
  104. package/src/devserver/module.ts +39 -1
  105. package/test/assembly/cookie.spec.ts +302 -0
  106. package/test/assembly/example.spec.ts +5 -1
  107. package/test/assembly/ssr.spec.ts +94 -0
  108. package/test/devserver.test.ts +42 -0
  109. package/test/ssr-render.test.ts +128 -0
  110. package/test/ssr-template.test.tsx +348 -0
@@ -0,0 +1,36 @@
1
+ import { assignSlotIds } from './template.js';
2
+ function hashLiteral(hash) {
3
+ const bytes = Array.from(hash.values())
4
+ .map((b) => `0x${b.toString(16).padStart(2, '0')}`)
5
+ .join(', ');
6
+ return `[${bytes}]`;
7
+ }
8
+ function isIdent(name) {
9
+ return /^[A-Za-z_$][\w$]*$/.test(name);
10
+ }
11
+ export function generateSlotsModule(routeName, slots, hash) {
12
+ if (hash.length !== 32)
13
+ throw new Error('toil ssr: coherence hash must be 32 bytes');
14
+ const ids = assignSlotIds(slots);
15
+ const members = [];
16
+ for (const [name, id] of ids) {
17
+ if (!isIdent(name)) {
18
+ throw new Error(`toil ssr: hole id "${name}" in route "${routeName}" is not a valid identifier ` +
19
+ `(use [A-Za-z_][\\w]*) so it can be a Slot enum member`);
20
+ }
21
+ members.push(` ${name} = ${id},`);
22
+ }
23
+ return `// AUTO-GENERATED by toil (edge SSR). Do not edit.
24
+ // Route: ${routeName}. Slot ids match the deployed .slots manifest; HASH is the
25
+ // coherence hash the host checks against the template (deploy-skew guard).
26
+
27
+ /** Stable hole ids for this route's template. */
28
+ export enum Slot {
29
+ ${members.join('\n')}
30
+ }
31
+
32
+ /** Coherence hash (32 bytes) baked into the guest and echoed in every values
33
+ * envelope; the host rejects a response whose hash != the deployed template. */
34
+ export const HASH: StaticArray<u8> = ${hashLiteral(hash)};
35
+ `;
36
+ }
@@ -0,0 +1,29 @@
1
+ import { type ComponentType, type Context, type ReactNode } from 'react';
2
+ import { type ResolvedToilConfig } from './config.js';
3
+ export interface RouteRenderInput {
4
+ name: string;
5
+ Page: ComponentType;
6
+ layouts: ComponentType<{
7
+ children?: ReactNode;
8
+ }>[];
9
+ loaderData: unknown;
10
+ loaderContext: Context<unknown> | null;
11
+ setSsrBuild: (on: boolean) => void;
12
+ shell: string;
13
+ }
14
+ export interface TemplateArtifacts {
15
+ name: string;
16
+ tmpl: Buffer;
17
+ slotsBin: Buffer;
18
+ slotsModule: string;
19
+ hash: Buffer;
20
+ slotCount: number;
21
+ }
22
+ export declare function assembleRouteElement(Page: ComponentType, layouts: ComponentType<{
23
+ children?: ReactNode;
24
+ }>[], loaderData: unknown, loaderContext: Context<unknown> | null): ReactNode;
25
+ export declare function injectIntoShell(shell: string, routeHtml: string): string;
26
+ export declare function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts;
27
+ export declare function writeTemplateArtifacts(ssrDir: string, art: TemplateArtifacts): void;
28
+ export declare function routeTemplateName(pattern: string): string;
29
+ export declare function extractTemplates(cfg: ResolvedToilConfig, hostName?: string): Promise<string[]>;
@@ -0,0 +1,150 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createElement, } from 'react';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import { createServer } from 'vite';
6
+ import { findLayout, findSpecialChain } from './generate.js';
7
+ import { scanRoutes } from './routes.js';
8
+ import { generateSlotsModule } from './ssr-codegen.js';
9
+ import { assignSlotIds, coherenceHash, encodeSlots, extractFromHtml, } from './template.js';
10
+ import { createViteConfig } from './vite.js';
11
+ const SSR_MARKER = '<template id="__toil_ssr"></template>';
12
+ const ROOT_DIV = '<div id="root"></div>';
13
+ export function assembleRouteElement(Page, layouts, loaderData, loaderContext) {
14
+ let node = createElement(Page);
15
+ if (loaderContext) {
16
+ node = createElement(loaderContext.Provider, { value: loaderData }, node);
17
+ }
18
+ for (let i = layouts.length - 1; i >= 0; i--) {
19
+ node = createElement(layouts[i], null, node);
20
+ }
21
+ return node;
22
+ }
23
+ export function injectIntoShell(shell, routeHtml) {
24
+ if (!shell.includes(ROOT_DIV)) {
25
+ throw new Error('toil ssr: built shell has no empty <div id="root"></div> to splice into');
26
+ }
27
+ return shell.replace(ROOT_DIV, `<div id="root">${routeHtml}</div>${SSR_MARKER}`);
28
+ }
29
+ export function extractRouteTemplate(input) {
30
+ const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext);
31
+ input.setSsrBuild(true);
32
+ let routeHtml;
33
+ try {
34
+ routeHtml = renderToStaticMarkup(element);
35
+ }
36
+ finally {
37
+ input.setSsrBuild(false);
38
+ }
39
+ const full = injectIntoShell(input.shell, routeHtml);
40
+ const extracted = extractFromHtml(full);
41
+ const ids = assignSlotIds(extracted.slots);
42
+ const hash = coherenceHash(extracted.tmpl, extracted.slots);
43
+ return {
44
+ name: input.name,
45
+ tmpl: extracted.tmpl,
46
+ slotsBin: encodeSlots(extracted.tmpl.length, hash, extracted.slots, ids),
47
+ slotsModule: generateSlotsModule(input.name, extracted.slots, hash),
48
+ hash,
49
+ slotCount: extracted.slots.length,
50
+ };
51
+ }
52
+ export function writeTemplateArtifacts(ssrDir, art) {
53
+ fs.mkdirSync(ssrDir, { recursive: true });
54
+ fs.writeFileSync(path.join(ssrDir, `${art.name}.tmpl`), art.tmpl);
55
+ fs.writeFileSync(path.join(ssrDir, `${art.name}.slots`), art.slotsBin);
56
+ fs.writeFileSync(path.join(ssrDir, `${art.name}.slots.ts`), art.slotsModule);
57
+ }
58
+ export function routeTemplateName(pattern) {
59
+ const n = pattern.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
60
+ return n.length > 0 ? n : 'index';
61
+ }
62
+ function sampleParams(pattern) {
63
+ const params = {};
64
+ for (const m of pattern.matchAll(/[:*]+([A-Za-z0-9_]+)/g)) {
65
+ params[m[1]] = 'sample';
66
+ }
67
+ return params;
68
+ }
69
+ export async function extractTemplates(cfg, hostName = 'edge') {
70
+ const routes = scanRoutes(cfg.routesAbsDir).filter((r) => r.slot === undefined && !r.intercept);
71
+ if (routes.length === 0)
72
+ return [];
73
+ const outDir = path.resolve(cfg.root, cfg.outDir);
74
+ const stashed = path.join(cfg.toilDir, 'shell.html');
75
+ const shellPath = fs.existsSync(stashed) ? stashed : path.join(outDir, 'index.html');
76
+ if (!fs.existsSync(shellPath))
77
+ return [];
78
+ const shell = fs.readFileSync(shellPath, 'utf8');
79
+ const warn = (msg) => {
80
+ process.stderr.write(` toil: SSR ${msg}\n`);
81
+ };
82
+ const server = await createServer({
83
+ ...(await createViteConfig(cfg)),
84
+ server: { middlewareMode: true, hmr: false },
85
+ appType: 'custom',
86
+ logLevel: 'silent',
87
+ });
88
+ const client = (await server.ssrLoadModule('toiljs/client'));
89
+ const ssrDir = path.join(outDir, '_ssr');
90
+ const hostsTmplDir = path.join(cfg.root, 'hosts', hostName, '_tmpl');
91
+ const generated = [];
92
+ const index = [];
93
+ try {
94
+ for (const r of routes) {
95
+ let mod;
96
+ try {
97
+ mod = (await server.ssrLoadModule(r.file));
98
+ }
99
+ catch (err) {
100
+ warn(`skipped ${r.pattern} (${err instanceof Error ? err.message : String(err)})`);
101
+ continue;
102
+ }
103
+ if (mod.ssr !== true)
104
+ continue;
105
+ try {
106
+ const params = sampleParams(r.pattern);
107
+ const loaderData = typeof mod.loader === 'function'
108
+ ? await mod.loader({ params, searchParams: new URLSearchParams() })
109
+ : undefined;
110
+ const layoutFiles = [
111
+ ...(findLayout(cfg) ? [findLayout(cfg)] : []),
112
+ ...findSpecialChain(cfg, r.file, 'layout', false),
113
+ ];
114
+ const layouts = [];
115
+ for (const lf of layoutFiles) {
116
+ const lm = (await server.ssrLoadModule(lf));
117
+ layouts.push(lm.default);
118
+ }
119
+ const name = routeTemplateName(r.pattern);
120
+ const art = extractRouteTemplate({
121
+ name,
122
+ Page: mod.default,
123
+ layouts,
124
+ loaderData,
125
+ loaderContext: client.LoaderDataContext,
126
+ setSsrBuild: client.__setSsrBuild,
127
+ shell,
128
+ });
129
+ writeTemplateArtifacts(ssrDir, art);
130
+ fs.mkdirSync(hostsTmplDir, { recursive: true });
131
+ fs.copyFileSync(path.join(ssrDir, `${name}.tmpl`), path.join(hostsTmplDir, `${name}.tmpl`));
132
+ fs.copyFileSync(path.join(ssrDir, `${name}.slots`), path.join(hostsTmplDir, `${name}.slots`));
133
+ index.push({ route: r.pattern, name, hash: art.hash.toString('hex') });
134
+ generated.push(r.pattern);
135
+ }
136
+ catch (err) {
137
+ warn(`skipped ${r.pattern} (render failed: ${err instanceof Error ? err.message : String(err)}) — falls back to client rendering`);
138
+ }
139
+ }
140
+ }
141
+ finally {
142
+ await server.close();
143
+ }
144
+ if (generated.length > 0) {
145
+ fs.mkdirSync(ssrDir, { recursive: true });
146
+ fs.writeFileSync(path.join(ssrDir, 'templates.json'), JSON.stringify(index, null, 2));
147
+ process.stdout.write(` ✓ extracted ${String(generated.length)} SSR template${generated.length === 1 ? '' : 's'}\n`);
148
+ }
149
+ return generated;
150
+ }
@@ -0,0 +1,22 @@
1
+ export type SlotKind = 'text' | 'raw' | 'attr' | 'repeat';
2
+ export declare function kindByte(kind: SlotKind): number;
3
+ export interface SlotRecord {
4
+ id: string;
5
+ kind: SlotKind;
6
+ offset: number;
7
+ rowTemplate?: Buffer;
8
+ rowSlots?: SlotRecord[];
9
+ }
10
+ export interface Extracted {
11
+ tmpl: Buffer;
12
+ slots: SlotRecord[];
13
+ }
14
+ export declare function extractFromHtml(html: string): Extracted;
15
+ export declare function assignSlotIds(slots: SlotRecord[]): Map<string, number>;
16
+ export declare function encodeSlots(tmplLen: number, hash: Buffer, slots: SlotRecord[], slotIds: Map<string, number>): Buffer;
17
+ export declare function coherenceHash(tmpl: Buffer, slots: SlotRecord[]): Buffer;
18
+ export declare function reactEscapeHtml(s: string): string;
19
+ export declare function spliceTemplate(tmpl: Buffer, inserts: {
20
+ offset: number;
21
+ value: Buffer;
22
+ }[]): Buffer;
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto';
2
+ const START = String.fromCharCode(0xe000);
3
+ const END = String.fromCharCode(0xe002);
4
+ export function kindByte(kind) {
5
+ switch (kind) {
6
+ case 'text':
7
+ return 0;
8
+ case 'raw':
9
+ return 1;
10
+ case 'attr':
11
+ return 2;
12
+ case 'repeat':
13
+ return 3;
14
+ }
15
+ }
16
+ function scan(html) {
17
+ let out = '';
18
+ let byteLen = 0;
19
+ const slots = [];
20
+ let i = 0;
21
+ const emit = (chunk) => {
22
+ out += chunk;
23
+ byteLen += Buffer.byteLength(chunk, 'utf8');
24
+ };
25
+ while (i < html.length) {
26
+ const start = html.indexOf(START, i);
27
+ if (start === -1) {
28
+ emit(html.slice(i));
29
+ break;
30
+ }
31
+ if (start > i)
32
+ emit(html.slice(i, start));
33
+ const kindChar = html[start + 1];
34
+ const tokEnd = html.indexOf(END, start + 2);
35
+ if (tokEnd === -1)
36
+ throw new Error('toil ssr: unterminated sentinel token');
37
+ const id = html.slice(start + 2, tokEnd);
38
+ const afterTok = tokEnd + 1;
39
+ if (kindChar === 'R') {
40
+ const closeTok = START + 'r' + id + END;
41
+ const closeIdx = html.indexOf(closeTok, afterTok);
42
+ if (closeIdx === -1) {
43
+ throw new Error(`toil ssr: unterminated repeat region "${id}"`);
44
+ }
45
+ const innerHtml = html.slice(afterTok, closeIdx);
46
+ const inner = scan(innerHtml);
47
+ slots.push({
48
+ id,
49
+ kind: 'repeat',
50
+ offset: byteLen,
51
+ rowTemplate: Buffer.from(inner.text, 'utf8'),
52
+ rowSlots: inner.slots,
53
+ });
54
+ i = closeIdx + closeTok.length;
55
+ continue;
56
+ }
57
+ const kind = kindChar === 't' ? 'text' : kindChar === 'h' ? 'raw' : kindChar === 'a' ? 'attr' : null;
58
+ if (kind === null) {
59
+ throw new Error(`toil ssr: unknown sentinel kind "${kindChar ?? ''}"`);
60
+ }
61
+ slots.push({ id, kind, offset: byteLen });
62
+ i = afterTok;
63
+ }
64
+ return { text: out, byteLen, slots };
65
+ }
66
+ export function extractFromHtml(html) {
67
+ const r = scan(html);
68
+ return { tmpl: Buffer.from(r.text, 'utf8'), slots: r.slots };
69
+ }
70
+ export function assignSlotIds(slots) {
71
+ const ids = new Map();
72
+ let next = 0;
73
+ for (const s of slots) {
74
+ if (!ids.has(s.id))
75
+ ids.set(s.id, next++);
76
+ }
77
+ return ids;
78
+ }
79
+ export function encodeSlots(tmplLen, hash, slots, slotIds) {
80
+ if (hash.length !== 32)
81
+ throw new Error('toil ssr: coherence hash must be 32 bytes');
82
+ const buf = Buffer.alloc(4 + 2 + 2 + 4 + 32 + 2 + slots.length * 8);
83
+ let o = 0;
84
+ buf.write('TSLT', o, 'ascii');
85
+ o += 4;
86
+ buf.writeUInt16LE(1, o);
87
+ o += 2;
88
+ buf.writeUInt16LE(0, o);
89
+ o += 2;
90
+ buf.writeUInt32LE(tmplLen, o);
91
+ o += 4;
92
+ hash.copy(buf, o);
93
+ o += 32;
94
+ buf.writeUInt16LE(slots.length, o);
95
+ o += 2;
96
+ for (const s of slots) {
97
+ const id = slotIds.get(s.id);
98
+ if (id === undefined)
99
+ throw new Error(`toil ssr: no slot id for "${s.id}"`);
100
+ buf.writeUInt32LE(s.offset, o);
101
+ o += 4;
102
+ buf.writeUInt16LE(id, o);
103
+ o += 2;
104
+ buf.writeUInt8(kindByte(s.kind), o);
105
+ o += 1;
106
+ buf.writeUInt8(0, o);
107
+ o += 1;
108
+ }
109
+ return buf;
110
+ }
111
+ function canonicalManifest(slots) {
112
+ return JSON.stringify(slots.map((s) => ({
113
+ id: s.id,
114
+ kind: s.kind,
115
+ offset: s.offset,
116
+ row: s.rowSlots ? canonicalManifest(s.rowSlots) : undefined,
117
+ rowLen: s.rowTemplate ? s.rowTemplate.length : undefined,
118
+ })));
119
+ }
120
+ export function coherenceHash(tmpl, slots) {
121
+ return createHash('sha256')
122
+ .update(tmpl)
123
+ .update('\0')
124
+ .update(canonicalManifest(slots), 'utf8')
125
+ .digest();
126
+ }
127
+ export function reactEscapeHtml(s) {
128
+ let out = '';
129
+ let last = 0;
130
+ for (let i = 0; i < s.length; i++) {
131
+ let rep;
132
+ switch (s.charCodeAt(i)) {
133
+ case 34:
134
+ rep = '&quot;';
135
+ break;
136
+ case 38:
137
+ rep = '&amp;';
138
+ break;
139
+ case 39:
140
+ rep = '&#x27;';
141
+ break;
142
+ case 60:
143
+ rep = '&lt;';
144
+ break;
145
+ case 62:
146
+ rep = '&gt;';
147
+ break;
148
+ default:
149
+ continue;
150
+ }
151
+ out += s.slice(last, i) + rep;
152
+ last = i + 1;
153
+ }
154
+ return last === 0 ? s : out + s.slice(last);
155
+ }
156
+ export function spliceTemplate(tmpl, inserts) {
157
+ const parts = [];
158
+ let prev = 0;
159
+ for (const ins of inserts) {
160
+ if (ins.offset > prev)
161
+ parts.push(tmpl.subarray(prev, ins.offset));
162
+ if (ins.value.length > 0)
163
+ parts.push(ins.value);
164
+ prev = ins.offset;
165
+ }
166
+ if (tmpl.length > prev)
167
+ parts.push(tmpl.subarray(prev));
168
+ return Buffer.concat(parts);
169
+ }