@transclude/core 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. package/src/worker.js +87 -0
@@ -0,0 +1,492 @@
1
+ // Type checking and type extraction, both by TypeScript.
2
+ //
3
+ // Shims live in memory at `<file>.html.js`, never on disk. Naming them after the
4
+ // source file is what makes their relative imports resolve the way the author
5
+ // wrote them. A shim in a parallel directory would have to rewrite every import,
6
+ // and rewriting is where source mapping breaks down.
7
+ //
8
+ // JavaScript rather than TypeScript because a JSDoc `@type` in the author's own
9
+ // `<script props>` is honoured in a .js file and silently ignored in a .ts one.
10
+ //
11
+ // Shims are self-contained: route contexts and component props are inlined as
12
+ // type literals rather than imported. transclude-env.d.ts is written *from* the shims,
13
+ // so it cannot also be an input to them.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import ts from 'typescript';
18
+ import { buildEndpointShim, buildShim, originalOffset } from './compiler/shim.js';
19
+ import { splitBlocks, readFlags } from './compiler/index.js';
20
+ import { resolveRoutesDir, scanRoutes } from './routes.js';
21
+
22
+ /**
23
+ * Annotations are optional, so `noImplicitAny` is off: an unannotated parameter
24
+ * is `any` rather than an error, and the author writes plain modern JavaScript.
25
+ * That would normally allow reading an undeclared property on a type that came
26
+ * from an object literal. The shim gets that back by remapping the keys, so
27
+ * `${user.nmae}` is still an error.
28
+ *
29
+ * `strictNullChecks` stays on: `querySelector` really can return null, and that
30
+ * is a bug rather than a matter of taste. `strict: true` in the config turns the
31
+ * rest on for anyone who wants it.
32
+ */
33
+ const compilerOptions = (strict) => ({
34
+ target: ts.ScriptTarget.ESNext,
35
+ module: ts.ModuleKind.ESNext,
36
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
37
+ lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
38
+ strict,
39
+ strictNullChecks: true,
40
+ noImplicitAny: strict,
41
+ noEmit: true,
42
+ skipLibCheck: true,
43
+ allowJs: true,
44
+ checkJs: true,
45
+ types: [],
46
+ });
47
+
48
+ const TYPE_FORMAT =
49
+ ts.TypeFormatFlags.NoTruncation |
50
+ ts.TypeFormatFlags.InTypeAlias |
51
+ ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType;
52
+
53
+ const LAYOUT_FILE = '_layout.html';
54
+
55
+ /**
56
+ * @param {{ root: string, appDir: string, routesDir: string, elementsDir: string,
57
+ * strict?: boolean }} options
58
+ * @returns {{ files: Function, update: Function, rebuild: Function,
59
+ * check: Function, quickInfo: Function, describe: Function }}
60
+ */
61
+ export function createChecker({
62
+ root,
63
+ appDir = 'app',
64
+ elementsDir = 'elements',
65
+ routesDir = 'routes',
66
+ strict = false,
67
+ }) {
68
+ const app = path.resolve(root, appDir);
69
+ const options = compilerOptions(Boolean(strict));
70
+ const shims = new Map();
71
+ const versions = new Map();
72
+ const overlays = new Map();
73
+
74
+ const shimPath = (file) => `${file}.js`;
75
+
76
+ const host = {
77
+ getScriptFileNames: () => [...shims.keys()],
78
+ getScriptVersion: (name) => String(versions.get(name) ?? 0),
79
+ getScriptSnapshot: (name) => {
80
+ const shim = shims.get(name);
81
+ if (shim) return ts.ScriptSnapshot.fromString(shim.code);
82
+ if (!fs.existsSync(name)) return undefined;
83
+ return ts.ScriptSnapshot.fromString(fs.readFileSync(name, 'utf8'));
84
+ },
85
+ getCurrentDirectory: () => root,
86
+ getCompilationSettings: () => options,
87
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
88
+ fileExists: (name) => shims.has(name) || ts.sys.fileExists(name),
89
+ readFile: (name) => (shims.has(name) ? shims.get(name).code : ts.sys.readFile(name)),
90
+ readDirectory: ts.sys.readDirectory,
91
+ directoryExists: ts.sys.directoryExists,
92
+ getDirectories: ts.sys.getDirectories,
93
+ };
94
+
95
+ const service = ts.createLanguageService(host, ts.createDocumentRegistry());
96
+
97
+ const install = (file, built) => {
98
+ const name = shimPath(file);
99
+ shims.set(name, built);
100
+ versions.set(name, (versions.get(name) ?? 0) + 1);
101
+ return built;
102
+ };
103
+
104
+ const sourceOf = (file) => overlays.get(file) ?? fs.readFileSync(file, 'utf8');
105
+
106
+ /** The type of one of a shim's marker exports. What tsc made of the file. */
107
+ const exportTypeOf = (file, name) => {
108
+ const program = service.getProgram();
109
+ const source = program?.getSourceFile(shimPath(file));
110
+ if (!source) return 'unknown';
111
+
112
+ const checker = program.getTypeChecker();
113
+ const moduleSymbol = checker.getSymbolAtLocation(source);
114
+ const data =
115
+ moduleSymbol &&
116
+ checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.getName() === name);
117
+ if (!data) return 'unknown';
118
+
119
+ const type = checker.getTypeOfSymbolAtLocation(data, data.valueDeclaration ?? source);
120
+ const text = checker.typeToString(type, undefined, TYPE_FORMAT);
121
+ return text === 'any' ? 'unknown' : text;
122
+ };
123
+
124
+ const dataTypeOf = (file) => exportTypeOf(file, '__data');
125
+ const propTypeOf = (file) => exportTypeOf(file, '__propTypes');
126
+ const memberTypeOf = (file) => exportTypeOf(file, '__members');
127
+ const stateTypeOf = (file) => exportTypeOf(file, '__state');
128
+
129
+ // ---- the project, in the only order that resolves ------------------------
130
+
131
+ const elementFiles = (dir) =>
132
+ readDirSafe(path.resolve(app, dir))
133
+ .filter((entry) => entry.endsWith('.html') && entry.slice(0, -5).includes('-'))
134
+ .map((entry) => path.resolve(app, dir, entry));
135
+
136
+ const componentFiles = () => elementFiles(elementsDir);
137
+
138
+ // The file decides whether the element has a shadow root, so it also decides
139
+ // what `this.shadowRoot` means inside its <script>. Read with the compiler's
140
+ // own reader, or the types would describe a different element than the one
141
+ // that ships.
142
+ const isShadow = (file) => Boolean(safeFlags(file).shadow);
143
+
144
+ const safeFlags = (file) => {
145
+ try {
146
+ return readFlags(fs.readFileSync(file, 'utf8'), path.basename(file));
147
+ } catch {
148
+ // A file that will not parse is reported by the checker itself. Guessing
149
+ // light here only decides which shim shape it gets while that is fixed.
150
+ return {};
151
+ }
152
+ };
153
+
154
+ const layoutFiles = (dir = resolveRoutesDir(app, routesDir), out = new Map()) => {
155
+ if (!fs.existsSync(dir)) return out;
156
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
157
+ const full = path.join(dir, entry.name);
158
+ if (entry.isDirectory()) layoutFiles(full, out);
159
+ else if (entry.name === LAYOUT_FILE) {
160
+ const relative = path.relative(resolveRoutesDir(app, routesDir), dir);
161
+ out.set(relative ? relative.split(path.sep).join('-') : 'root', full);
162
+ }
163
+ }
164
+ return out;
165
+ };
166
+
167
+ const ancestorsOf = (layoutId, layouts) => {
168
+ const parts = layoutId === 'root' ? [] : layoutId.split('-');
169
+ const chain = [];
170
+ for (let i = 0; i <= parts.length; i++) {
171
+ const id = i === 0 ? 'root' : parts.slice(0, i).join('-');
172
+ if (layouts.has(id) && id !== layoutId) chain.push(id);
173
+ }
174
+ return chain;
175
+ };
176
+
177
+ const chainFor = (rel, layouts) => {
178
+ const dirs = path.dirname(rel).split(path.sep).filter((d) => d && d !== '.');
179
+ const chain = [];
180
+ for (let i = 0; i <= dirs.length; i++) {
181
+ const id = i === 0 ? 'root' : dirs.slice(0, i).join('-');
182
+ if (layouts.has(id)) chain.push(id);
183
+ }
184
+ return chain;
185
+ };
186
+
187
+ /** Later wins, which is what a nearer layout should do. */
188
+ const mergeTypes = (types) =>
189
+ types
190
+ .filter((type) => type && type !== 'unknown')
191
+ .reduce(
192
+ (left, right) => (left === '{}' ? right : `Omit<${left}, keyof ${right}> & ${right}`),
193
+ '{}',
194
+ );
195
+
196
+ // `request` is the platform's `Request`, not a router's wrapper. Reading a form
197
+ // is `await request.formData()` and nothing to look up. It is null while
198
+ // prerendering, where there is no request to read.
199
+ //
200
+ // `action` is whatever the page's handler for this method returned, so a POST
201
+ // and a GET render through the same loader.
202
+ //
203
+ // `response` is the part of the answer that is not markup. Change it, since the
204
+ // object is shared with every loader in the chain and with the server, or return
205
+ // a `Response` to answer the request yourself and skip rendering.
206
+ //
207
+ // `cookies` reads the request and writes into that same envelope. `__Cookies`
208
+ // is defined by the shim, which is the only thing that reads this string.
209
+ /**
210
+ * What an endpoint's handler is handed. The same route context a loader gets,
211
+ * without the two parts that only exist because a page renders: `layout` is
212
+ * added by the chain walk, and `request` is never null because prerendering
213
+ * never runs an endpoint.
214
+ */
215
+ const endpointLiteral = (params) =>
216
+ `{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
217
+ `route: { id: string; pattern: string; path: string }; ` +
218
+ `request: Request; fragment: string | null; ` +
219
+ `response: { status: number; headers: Headers }; cookies: __Cookies; ` +
220
+ `absolute: (path: string) => string; revalidateTag: (tag: string) => void }`;
221
+
222
+ const contextLiteral = (params, layoutType) =>
223
+ `{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
224
+ `route: { id: string; pattern: string; path: string }; ` +
225
+ `layout: ${layoutType}; request: Request | null; fragment: string | null; ` +
226
+ `action: unknown; response: { status: number; headers: Headers }; ` +
227
+ `cookies: __Cookies; htmlAttrs: Record<string, string | boolean | null>; ` +
228
+ `absolute: (path: string) => string; revalidateTag: (tag: string) => void }`;
229
+
230
+ /**
231
+ * Builds every shim in dependency order: components depend on nothing, a
232
+ * layout on the layouts above it, a page on its whole chain. Each step asks
233
+ * tsc what the previous one produced.
234
+ */
235
+ const build = () => {
236
+ // Light and shadow elements take props the same way, so they are checked
237
+ // the same way and their prop types reach whatever renders them.
238
+ const componentProps = new Map();
239
+ const componentMembers = new Map();
240
+ const files = componentFiles();
241
+ for (const file of files) {
242
+ install(file, buildShim(sourceOf(file), { kind: 'component', shadow: isShadow(file) }));
243
+ }
244
+ for (const file of files) {
245
+ const tag = path.basename(file, '.html');
246
+ const blocks = splitBlocks(sourceOf(file));
247
+ componentProps.set(tag, propTypeOf(file));
248
+ // An element with neither block registers nothing, so it has no accessors
249
+ // and no members. Saying otherwise in transclude-env.d.ts would be a claim
250
+ // the browser does not back up.
251
+ // Members live in the client block now, so the shim is what knows whether
252
+ // there are any. An empty `__Members` means the block exported no
253
+ // `prototype`, and having a client block at all is reason enough to upgrade.
254
+ const members = memberTypeOf(file);
255
+ componentMembers.set(tag, {
256
+ members: members && members !== '{}' ? members : null,
257
+ state: blocks.state ? stateTypeOf(file) : null,
258
+ upgrades: Boolean(blocks.state || blocks.client.length),
259
+ });
260
+ }
261
+
262
+ const layouts = layoutFiles();
263
+ const layoutData = new Map();
264
+
265
+ for (const [id, file] of [...layouts].sort((a, b) => depthOf(a[0]) - depthOf(b[0]))) {
266
+ const above = mergeTypes(ancestorsOf(id, layouts).map((ancestor) => layoutData.get(ancestor)));
267
+ install(
268
+ file,
269
+ buildShim(sourceOf(file), {
270
+ kind: 'layout',
271
+ contextType: contextLiteral([], above),
272
+ componentProps,
273
+ }),
274
+ );
275
+ layoutData.set(id, dataTypeOf(file));
276
+ }
277
+
278
+ const { routes, endpoints, notFound } = scanRoutes(resolveRoutesDir(app, routesDir));
279
+
280
+ for (const route of endpoints) {
281
+ install(route.file, buildEndpointShim(sourceOf(route.file), {
282
+ contextType: endpointLiteral(route.params),
283
+ }));
284
+ }
285
+
286
+ const pages = new Map();
287
+ for (const route of [...routes, notFound].filter(Boolean)) {
288
+ const above = mergeTypes(chainFor(route.rel, layouts).map((id) => layoutData.get(id)));
289
+ const context = contextLiteral(route.params, above);
290
+ install(
291
+ route.file,
292
+ buildShim(sourceOf(route.file), { kind: 'page', contextType: context, componentProps }),
293
+ );
294
+ pages.set(route.id, { route, context });
295
+ }
296
+
297
+ return { componentProps, componentMembers, layouts, layoutData, pages };
298
+ };
299
+
300
+ let project = build();
301
+
302
+ const endpointFor = (file) => {
303
+ const { endpoints } = scanRoutes(resolveRoutesDir(app, routesDir));
304
+ return endpoints.find((route) => route.file === file) ?? null;
305
+ };
306
+
307
+ const contextFor = (file) => {
308
+ if (path.basename(file) === LAYOUT_FILE) {
309
+ const relative = path.relative(resolveRoutesDir(app, routesDir), path.dirname(file));
310
+ const id = relative ? relative.split(path.sep).join('-') : 'root';
311
+ return contextLiteral(
312
+ [],
313
+ mergeTypes(ancestorsOf(id, project.layouts).map((a) => project.layoutData.get(a))),
314
+ );
315
+ }
316
+ for (const { route, context } of project.pages.values()) {
317
+ if (route.file === file) return context;
318
+ }
319
+ return null;
320
+ };
321
+
322
+ const kindOf = (file) => {
323
+ if (file.startsWith(path.resolve(app, elementsDir))) return 'component';
324
+ return path.basename(file) === LAYOUT_FILE ? 'layout' : 'page';
325
+ };
326
+
327
+ const refresh = (file) => {
328
+ if (file.endsWith('.js')) {
329
+ const route = endpointFor(file);
330
+ return install(file, buildEndpointShim(sourceOf(file), {
331
+ contextType: endpointLiteral(route?.params ?? []),
332
+ }));
333
+ }
334
+ const kind = kindOf(file);
335
+ return install(
336
+ file,
337
+ buildShim(sourceOf(file), {
338
+ kind,
339
+ shadow: isShadow(file),
340
+ contextType: kind === 'component' ? null : contextFor(file),
341
+ componentProps: project.componentProps,
342
+ }),
343
+ );
344
+ };
345
+
346
+ return {
347
+ files() {
348
+ const found = componentFiles();
349
+ const dir = resolveRoutesDir(app, routesDir);
350
+ walkHtml(dir, found);
351
+ for (const route of scanRoutes(dir).endpoints) found.push(route.file);
352
+ return found;
353
+ },
354
+
355
+ /** Replaces a file's contents without touching disk, for an editor buffer. */
356
+ update(file, source) {
357
+ overlays.set(file, source);
358
+ refresh(file);
359
+ },
360
+
361
+ /** Re-derives every type. Needed after a file is added, renamed or removed. */
362
+ rebuild() {
363
+ project = build();
364
+ },
365
+
366
+ check(file) {
367
+ const shim = refresh(file);
368
+ const name = shimPath(file);
369
+
370
+ // A file that does not parse gets one diagnostic, not a cascade of type
371
+ // errors derived from the half of it that survived.
372
+ if (shim.syntaxErrors?.length) {
373
+ return shim.syntaxErrors.map((error) => ({
374
+ file,
375
+ offset: error.offset,
376
+ length: 1,
377
+ code: 1005,
378
+ message: error.message,
379
+ severity: 'error',
380
+ }));
381
+ }
382
+
383
+ const out = [];
384
+ for (const diagnostic of [
385
+ ...service.getSyntacticDiagnostics(name),
386
+ ...service.getSemanticDiagnostics(name),
387
+ ]) {
388
+ const offset = originalOffset(shim.chunks, diagnostic.start ?? 0);
389
+ // A diagnostic with no home is one about generated scaffolding. Dropping
390
+ // it is right, but it means anything that can carry a diagnostic has to be
391
+ // mapped, or it disappears without a word.
392
+ if (offset === null) continue;
393
+
394
+ out.push({
395
+ file,
396
+ offset,
397
+ length: diagnostic.length ?? 1,
398
+ code: diagnostic.code,
399
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '),
400
+ severity: diagnostic.category === ts.DiagnosticCategory.Error ? 'error' : 'warning',
401
+ });
402
+ }
403
+ return out.sort((a, b) => a.offset - b.offset);
404
+ },
405
+
406
+ /** Type of the expression at a source offset, for editor hovers. */
407
+ quickInfo(file, offset) {
408
+ const shim = shims.get(shimPath(file)) ?? refresh(file);
409
+ const target = shim.chunks.find(
410
+ (chunk) =>
411
+ chunk.source !== null &&
412
+ offset >= chunk.source &&
413
+ offset < chunk.source + chunk.text.length,
414
+ );
415
+ if (!target) return null;
416
+
417
+ const info = service.getQuickInfoAtPosition(
418
+ shimPath(file),
419
+ target.start + (offset - target.source),
420
+ );
421
+ if (!info) return null;
422
+
423
+ return {
424
+ text: ts.displayPartsToString(info.displayParts),
425
+ documentation: ts.displayPartsToString(info.documentation ?? []),
426
+ };
427
+ },
428
+
429
+ /** Everything transclude-env.d.ts is written from. */
430
+ describe() {
431
+ // Light elements are described separately: they have props but no shadow
432
+ // root, so nothing about `this.shadowRoot` applies to them.
433
+ const partialTags = new Set(
434
+ componentFiles()
435
+ .filter((file) => !isShadow(file))
436
+ .map((file) => path.basename(file, '.html')),
437
+ );
438
+ return {
439
+ components: [...project.componentProps]
440
+ .filter(([tag]) => !partialTags.has(tag))
441
+ .map(([tag, type]) => ({ tag, type, ...project.componentMembers.get(tag) })),
442
+ partials: [...project.componentProps]
443
+ .filter(([tag]) => partialTags.has(tag))
444
+ .map(([tag, type]) => ({ tag, type, ...project.componentMembers.get(tag) })),
445
+ layouts: [...project.layoutData].map(([id, type]) => ({
446
+ id,
447
+ type,
448
+ context: contextFor(project.layouts.get(id)),
449
+ })),
450
+ pages: [...project.pages].map(([id, { route, context }]) => ({
451
+ id,
452
+ params: route.params,
453
+ pattern: route.pattern,
454
+ context,
455
+ type: dataTypeOf(route.file),
456
+ })),
457
+ };
458
+ },
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Line and column for an offset, for anything that reports to a human.
464
+ *
465
+ * @param {string} source
466
+ * @param {number} offset
467
+ * @returns {{ line: number, column: number }} both 1-based, for a message a reader can follow
468
+ */
469
+ export function positionAt(source, offset) {
470
+ const before = source.slice(0, offset);
471
+ const line = before.split('\n').length;
472
+ const column = offset - (before.lastIndexOf('\n') + 1);
473
+ return { line, column };
474
+ }
475
+
476
+ function depthOf(layoutId) {
477
+ return layoutId === 'root' ? 0 : layoutId.split('-').length;
478
+ }
479
+
480
+ function readDirSafe(dir) {
481
+ return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
482
+ }
483
+
484
+ function walkHtml(dir, out) {
485
+ if (!fs.existsSync(dir)) return out;
486
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
487
+ const full = path.join(dir, entry.name);
488
+ if (entry.isDirectory()) walkHtml(full, out);
489
+ else if (entry.name.endsWith('.html')) out.push(full);
490
+ }
491
+ return out;
492
+ }
package/src/worker.js ADDED
@@ -0,0 +1,87 @@
1
+ // The half of a worker entry that is not about one app.
2
+ //
3
+ // A worker has no filesystem, so its bytes are imported from a module the build
4
+ // emitted rather than read from a disk, and it has no `node:crypto`, so hashing
5
+ // is WebCrypto and therefore async. Those are runtime facts, so they live here.
6
+ // Which modules to import is an app fact, so that stays in the app's own entry.
7
+
8
+ /** base64 in, bytes out. `atob` is in every runtime that has no `Buffer`. */
9
+ const decode = (base64) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
10
+
11
+ /**
12
+ * A synchronous ETag for bytes that are fixed at build time. Hashing them with
13
+ * WebCrypto would make building the provider async for nothing. Content length
14
+ * plus an FNV-1a pass is a cache key, not a signature.
15
+ */
16
+ function etagOf(bytes) {
17
+ let hash = 0x811c9dc5;
18
+ for (const byte of bytes) {
19
+ hash ^= byte;
20
+ hash = Math.imul(hash, 0x01000193) >>> 0;
21
+ }
22
+ return `"${bytes.length.toString(36)}-${hash.toString(36)}"`;
23
+ }
24
+
25
+ /**
26
+ * The same entry shape the Node server builds from a disk. `encodings` is empty
27
+ * because nothing here is precompressed, and the code that reads it already
28
+ * treats an empty map as "identity is all there is".
29
+ *
30
+ * @param {Record<string, { body: string, type: string }>} map base64 from the build
31
+ * @returns {{ get: (pathname: string) => object|null }} the same entries with
32
+ * real bytes, behind the lookup `createApp` uses
33
+ */
34
+ export function bytesFrom(map) {
35
+ const built = new Map();
36
+ for (const [url, { type, body }] of Object.entries(map)) {
37
+ const bytes = decode(body);
38
+ built.set(url, { body: bytes, type, etag: etagOf(bytes), encodings: new Map() });
39
+ }
40
+ return {
41
+ get(pathname) {
42
+ // Trailing slashes are the same resource, as they are on the Node side.
43
+ return built.get(pathname.replace(/\/+$/, '') || '/') ?? null;
44
+ },
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Public files, as a handler rather than a directory. No byte ranges, because
50
+ * those need a filesystem and this runtime has none.
51
+ *
52
+ * @param {Record<string, object>} map
53
+ * @returns {Function} a Hono handler
54
+ */
55
+ export function fileHandler(map) {
56
+ const files = bytesFrom(map);
57
+ return async (c, next) => {
58
+ const hit = files.get(c.req.path);
59
+ if (!hit) return next();
60
+ c.header('Content-Type', hit.type);
61
+ c.header('ETag', hit.etag);
62
+ return c.body(hit.body);
63
+ };
64
+ }
65
+
66
+ /**
67
+ * A prerendered error page, which is sent as bytes and never revalidated.
68
+ *
69
+ * @param {{ body: string, type: string }|null|undefined} page base64 from the build
70
+ * @returns {object|null} the entry shape `send` expects
71
+ */
72
+ export const pageEntry = (page) =>
73
+ page && { body: decode(page.body), type: page.type, etag: '"error"', encodings: new Map() };
74
+
75
+ /**
76
+ * Rendered responses get the real thing, which on this runtime is async.
77
+ *
78
+ * @param {Uint8Array} body
79
+ * @returns {Promise<string>} a quoted ETag
80
+ */
81
+ export async function hash(body) {
82
+ const digest = await crypto.subtle.digest('SHA-1', body);
83
+ const bytes = new Uint8Array(digest);
84
+ let base64 = '';
85
+ for (const byte of bytes) base64 += String.fromCharCode(byte);
86
+ return `"${btoa(base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '').slice(0, 20)}"`;
87
+ }